mirror of
https://github.com/Permissionless-Software-Foundation/psffpp-payments.git
synced 2026-09-21 16:52:04 -07:00
Initial UI
This commit is contained in:
@@ -4,15 +4,16 @@ CashScript payout contracts for PSFFPP (Permissionless Software Foundation File
|
||||
|
||||
Users send BCH to a single **PoolContract** address. Once per payout epoch (blockheight-gated), the pool splits 20% to treasury and 80% into three **ShareContract** UTXOs (one per node). A K-of-M (2-of-3) operator quorum must sign before any share pays out.
|
||||
|
||||
Project docs live in [`docs/`](docs/). Start with [Why CashScript](docs/why-cashscript.md) for the high-level motivation (replacing PSF-token proof-of-burn with pure BCH payouts). For indexer/pin-service/client integration (pool-address registry, dual-mode validation), see [Infra changes](docs/infra-changes.md). External references: [CashScript](https://cashscript.org/) · Design: circular-economy trust-minimized federation (M=3, K=2).
|
||||
Project docs live in [`docs/`](docs/). Start with [Why CashScript](docs/why-cashscript.md) for the high-level motivation (replacing PSF-token proof-of-burn with pure BCH payouts). For indexer/pin-service/client integration (pool-address registry, dual-mode validation), see [Infra changes](docs/infra-changes.md). The operator React UI lives in [`ui/`](ui/) ([UI README](ui/README.md)); deferred UI features (migrate, registry announce, automation, etc.) are documented in [UI deferred](docs/ui-deferred.md). External references: [CashScript](https://cashscript.org/) · Design: circular-economy trust-minimized federation (M=3, K=2).
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
| In scope | Out of scope |
|
||||
|----------|--------------|
|
||||
| `ShareContract.cash`, `PoolContract.cash` | Cron (consolidate/split/broadcast) |
|
||||
| Compile + mock tests | Timecard web GUI / sig collection |
|
||||
| Compile + mock tests | Migrate / registry announce UI ([deferred](docs/ui-deferred.md)) |
|
||||
| Address + deploy record scripts | Treasury multisig, withdrawal caps |
|
||||
| Operator React UI (`ui/`) for consolidate / split / claim | |
|
||||
|
||||
## Epoch model
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# UI deferred features
|
||||
|
||||
Features intentionally **not** built in the first PSFFPP Payments React UI (`ui/`). This document describes what should be built later and how, so a follow-up implementation can reuse the existing browser service layer under `ui/src/services/psffpp/`.
|
||||
|
||||
Shipped in v1 UI: deploy-config paste/upload, pool status, consolidate, split (with fee carve), claim cosign JSON, claim assemble/broadcast. Wallet / BCH / Configuration / Sign views are retained from the wallet SPA fork.
|
||||
|
||||
---
|
||||
|
||||
## 1. Migrate (K-of-M)
|
||||
|
||||
### Why deferred
|
||||
|
||||
First UI release covers a single payout epoch end-to-end (deposit → consolidate → split → claim). Epoch rollover (`migrate`) is rarer, needs a destination address (usually a newly deployed next-epoch pool), and mirrors the claim cosign/assemble pattern that should be proven first.
|
||||
|
||||
### User story
|
||||
|
||||
As two federation operators, we want to move remaining value from an old PoolContract to a next-epoch Pool address after a new deploy, without a privileged admin key.
|
||||
|
||||
### Contract behavior (reference)
|
||||
|
||||
`PoolContract.migrate(s0, s1, s2)`:
|
||||
|
||||
- Requires ≥2-of-3 valid ECDSA signatures over the migrate transaction (`SIGHASH_ALL | SIGHASH_UTXOS`).
|
||||
- 1–2 pure-BCH outputs; destinations are **not** baked — authorized only by signatures.
|
||||
- Unused signature slots must be empty (`0x`), same NULLFAIL rule as claim.
|
||||
- See [`test/pool-migrate.mock.test.js`](../test/pool-migrate.mock.test.js) and [`contracts/PoolContract.cash`](../contracts/PoolContract.cash).
|
||||
|
||||
### Suggested UI
|
||||
|
||||
| Route | Role |
|
||||
|-------|------|
|
||||
| `/migrate/cosign` | Build migrate tx (pool UTXO(s) → destination address(es)), capture one operator’s ECDSA sig into a JSON envelope |
|
||||
| `/migrate` | Assemble ≥2 migrate-sig JSON files and broadcast |
|
||||
|
||||
Envelope sketch (parallel to `psffpp-share-claim-sig`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "psffpp-pool-migrate-sig",
|
||||
"version": 1,
|
||||
"network": "mainnet",
|
||||
"migrate": {
|
||||
"poolAddress": "bitcoincash:p…",
|
||||
"poolUtxo": { "txid": "…", "vout": 0, "satoshis": "…" },
|
||||
"outputs": [{ "address": "bitcoincash:p…", "satoshis": "…" }],
|
||||
"feeMode": "from_pool_or_companion",
|
||||
"sighash": "ALL|UTXOS",
|
||||
"signatureAlgorithm": "ECDSA",
|
||||
"constructorPubkeys": ["…", "…", "…"]
|
||||
},
|
||||
"signer": { "nodeIndex": 0, "name": "node0", "pubkey": "…" },
|
||||
"signature": "<hex>"
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation notes
|
||||
|
||||
- Reuse `SignatureTemplate` capture pattern from `ui/src/services/psffpp/claim-cosign.js`.
|
||||
- Reuse assemble/empty-slot logic from `claim-broadcast.js`.
|
||||
- Decide fee policy up front: deduct from pool vs companion fee input (companion is safer when leftover is near `minConsolidation`).
|
||||
- Require loaded deploy config for the **source** pool; destination may be pasted as a cashaddr (next-epoch pool) after a separate config derive step.
|
||||
- Acceptance: 2-of-3 migrate dry-run builds; broadcast moves value; 1-of-3 fails; mismatched envelope params rejected.
|
||||
|
||||
---
|
||||
|
||||
## 2. Next-epoch deploy helper
|
||||
|
||||
### Why deferred
|
||||
|
||||
Deploy today is CLI (`npm run addresses` / `npm run deploy`). The UI only loads an existing deploy JSON.
|
||||
|
||||
### User story
|
||||
|
||||
As an operator, I want to enter the next `splitBlockheight` (and optional membership tweaks), see new Share + Pool addresses, download a deploy record, then hand that address into Migrate.
|
||||
|
||||
### Suggested UI
|
||||
|
||||
- Route `/deploy` or extend `/contracts` with a “New epoch” wizard.
|
||||
- Client-side: validate config → `instantiateContracts` → show addresses (same as Contracts view).
|
||||
- Optional: download a deployment record JSON matching `scripts/deploy.mjs` shape (timestamps, locking bytecodes, artifact fingerprints). Fingerprints need a browser SHA-256 (Web Crypto), not Node `crypto`.
|
||||
- Do **not** broadcast funding from this wizard unless explicitly requested; publish address only.
|
||||
|
||||
### Acceptance
|
||||
|
||||
Derived addresses match `npm run addresses` for the same JSON; downloadable record is usable as `/contracts` paste input for the next epoch.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registry announce (OP_RETURN PSP1)
|
||||
|
||||
### Why deferred
|
||||
|
||||
Pin-service / indexer registry protocol is specified in [`infra-changes.md`](infra-changes.md) but not yet implemented in CLIs or UI. Only the trusted controller key should post updates.
|
||||
|
||||
### User story
|
||||
|
||||
As the registry controller, I post an append-only OP_RETURN announcing the current pool address so pin-service sync can validate historical payments.
|
||||
|
||||
### Suggested UI
|
||||
|
||||
- Route `/registry` (controller wallet only).
|
||||
- Build OP_RETURN payload per infra-changes protocol (`PSP1` + pool cashaddr / metadata).
|
||||
- Use in-app wallet to fund and broadcast; show confirmation + explorer link.
|
||||
- Read-only mode: sync controller address history and list known pool addresses (Electrum or consumer-api tx history).
|
||||
|
||||
### Acceptance
|
||||
|
||||
Posted tx is parseable by the future pin-service registry sync; UI lists prior announcements for the configured controller.
|
||||
|
||||
---
|
||||
|
||||
## 4. Automation (cron / agent)
|
||||
|
||||
### Why deferred
|
||||
|
||||
Browser UI is interactive. Monthly consolidate→split should eventually run unattended after the height gate without an operator clicking Broadcast.
|
||||
|
||||
### User story
|
||||
|
||||
As federation ops, a scheduled job consolidates if needed, waits for `splitBlockheight`, carves a fee UTXO, and splits — logging txids to a channel.
|
||||
|
||||
### Suggested build
|
||||
|
||||
- Prefer **Node CLIs** already in `scripts/` (`consolidate.mjs`, `split.mjs`) wrapped by systemd/cron or a small agent, not the CRA app.
|
||||
- Optionally extract `ui/src/services/psffpp/*` into a shared package used by both UI and agent to avoid drift.
|
||||
- Secrets: fee-payer WIF in env / file permissions; never in localStorage of a public web host.
|
||||
- Acceptance: dry-run then live run on mainnet smoke pool; idempotent if already split.
|
||||
|
||||
---
|
||||
|
||||
## 5. Federation admin UX
|
||||
|
||||
### Why deferred
|
||||
|
||||
v1 assumes a single pasted config. Production may juggle multiple epochs, remote config, and membership changes.
|
||||
|
||||
### User stories / features
|
||||
|
||||
| Feature | Approach |
|
||||
|---------|----------|
|
||||
| Multi-config profiles | Store named configs in localStorage; switch active profile for all contract views |
|
||||
| Remote config fetch | Load JSON from HTTPS URL / gist / IPFS CID with checksum; allow local override (was plan option C) |
|
||||
| Membership change | Wizard: new 3 pubkeys/PKHs → new deploy → migrate remaining → claim old shares under old contracts |
|
||||
| Read-only explorer links | Deep-link pool/share addresses to block explorers from Pool status |
|
||||
|
||||
### Acceptance
|
||||
|
||||
Operators can keep “epoch N” and “epoch N+1” configs without re-pasting; switching profile refreshes Pool status addresses.
|
||||
|
||||
---
|
||||
|
||||
## Reuse map
|
||||
|
||||
| Deferred feature | Existing building blocks |
|
||||
|------------------|--------------------------|
|
||||
| Migrate cosign/broadcast | `claim-cosign.js`, `claim-broadcast.js`, mock migrate tests |
|
||||
| Next-epoch deploy | `lib.js` `instantiateContracts`, Contracts view UI |
|
||||
| Registry announce | Wallet `send` / OP_RETURN helpers in bch-js; infra-changes.md |
|
||||
| Automation | Parent `scripts/*.mjs` |
|
||||
| Federation admin | `psffppDeployConfig` localStorage pattern in `hooks/state.js` |
|
||||
|
||||
## Out of scope reminders
|
||||
|
||||
Still out of scope for the payments UI unless product requirements change: treasury multisig / withdrawal caps, pin-service dual-mode screens, rewriting the UI to Vite, shipping a hardcoded production federation config with real keys.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"type": "psffpp-share-claim-sig",
|
||||
"version": 1,
|
||||
"network": "mainnet",
|
||||
"claim": {
|
||||
"shareIndex": 0,
|
||||
"shareName": "node0",
|
||||
"shareAddress": "bitcoincash:p0xu7h39z33ljatywsru6uge8e3x9xpkl939hjkm3d0psjd0gdw9u3xe00cld",
|
||||
"shareUtxo": {
|
||||
"txid": "db6820214a6459e33b684728a9caa18dcf7b554c6e09fcce436e69a3c98be681",
|
||||
"vout": 1,
|
||||
"satoshis": "28937"
|
||||
},
|
||||
"payoutAddress": "bitcoincash:qpc86qr9rcrys7q8274dsjaj5l6u50wfxcdclwtxc4",
|
||||
"payoutSatoshis": "28495",
|
||||
"feeSatoshis": "442",
|
||||
"feeMode": "from_share",
|
||||
"sighash": "ALL|UTXOS",
|
||||
"signatureAlgorithm": "ECDSA",
|
||||
"constructorPubkeys": [
|
||||
"0364d5fea1f8550dc16c1169888afcdf85ee77bcc1cb7e902ecb1f03e5fdf58f06",
|
||||
"02e7450eae4e52effc95cb3ddab97b162fa36ec20c0110c514a51ebe1a2e36ad51",
|
||||
"028a67119eecba54522bbbac60992c7f94a5dcbe55702d168956e1d246d6e3a203"
|
||||
]
|
||||
},
|
||||
"signer": {
|
||||
"nodeIndex": 0,
|
||||
"name": "node0",
|
||||
"pubkey": "0364d5fea1f8550dc16c1169888afcdf85ee77bcc1cb7e902ecb1f03e5fdf58f06",
|
||||
"cashAddress": "bitcoincash:qpc86qr9rcrys7q8274dsjaj5l6u50wfxcdclwtxc4"
|
||||
},
|
||||
"signature": "3044022042eadb2e90efc19c281f10138779fbd908b99c88b54c6309fa5cb1d9c1cba90602204e875c40f584be52f4434ec8aa3e5f0c5fd93b062c3fbd6cdf9afc599249ac4961",
|
||||
"notes": [
|
||||
"This signature unlocks ShareContract.claim for the exact claim tx described above.",
|
||||
"Do not change payoutSatoshis, fee, or UTXO without re-collecting signatures.",
|
||||
"Unused claim slots must be empty bytes (0x), never invalid non-empty sigs.",
|
||||
"BitcoinCash.signMessageWithPrivKey message signatures are NOT valid here."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"type": "psffpp-share-claim-sig",
|
||||
"version": 1,
|
||||
"network": "mainnet",
|
||||
"claim": {
|
||||
"shareIndex": 1,
|
||||
"shareName": "node1",
|
||||
"shareAddress": "bitcoincash:p0atphk8xnpn5gpy3pqwucudh7lu69nm0uym2vuraymrke37uyq8ze3frfq84",
|
||||
"shareUtxo": {
|
||||
"txid": "db6820214a6459e33b684728a9caa18dcf7b554c6e09fcce436e69a3c98be681",
|
||||
"vout": 2,
|
||||
"satoshis": "28937"
|
||||
},
|
||||
"payoutAddress": "bitcoincash:qppn0spr5hghje37slq40k4xqhet58vmdcnsuvnqq6",
|
||||
"payoutSatoshis": "28495",
|
||||
"feeSatoshis": "442",
|
||||
"feeMode": "from_share",
|
||||
"sighash": "ALL|UTXOS",
|
||||
"signatureAlgorithm": "ECDSA",
|
||||
"constructorPubkeys": [
|
||||
"0364d5fea1f8550dc16c1169888afcdf85ee77bcc1cb7e902ecb1f03e5fdf58f06",
|
||||
"02e7450eae4e52effc95cb3ddab97b162fa36ec20c0110c514a51ebe1a2e36ad51",
|
||||
"028a67119eecba54522bbbac60992c7f94a5dcbe55702d168956e1d246d6e3a203"
|
||||
]
|
||||
},
|
||||
"signer": {
|
||||
"nodeIndex": 1,
|
||||
"name": "node1",
|
||||
"pubkey": "02e7450eae4e52effc95cb3ddab97b162fa36ec20c0110c514a51ebe1a2e36ad51",
|
||||
"cashAddress": "bitcoincash:qppn0spr5hghje37slq40k4xjtet58vmdcfacp3c5k"
|
||||
},
|
||||
"signature": "3045022100f26d55ccee176fcdc2c2ca140a13a9916c93840b3c1867797c51963cf455d87502201f7a280a91ce789ab7932cfd977e278087d80497b94d75d2bce9c45a3798856961",
|
||||
"notes": [
|
||||
"This signature unlocks ShareContract.claim for the exact claim tx described above.",
|
||||
"Do not change payoutSatoshis, fee, or UTXO without re-collecting signatures.",
|
||||
"Unused claim slots must be empty bytes (0x), never invalid non-empty sigs.",
|
||||
"BitcoinCash.signMessageWithPrivKey message signatures are NOT valid here."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
build/
|
||||
docs/
|
||||
|
||||
.gitsigners
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"srcDir": "",
|
||||
"destDir": "",
|
||||
"files": "**/*.js",
|
||||
"command": "npm run lint"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Chris Troutner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pedigree
|
||||
|
||||
This code repository is forked from [react-bootstrap-web3-spa](https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa), and any updates to that upstream repository are pulled into this repository.
|
||||
@@ -0,0 +1,35 @@
|
||||
# PSFFPP Payments UI
|
||||
|
||||
React web app for PSFFPP CashScript payout contracts. Forked from [bch-wallet-web3-spa](https://github.com/Permissionless-Software-Foundation/bch-wallet-web3-spa).
|
||||
|
||||
Operators use the built-in BCH wallet to:
|
||||
|
||||
- Load federation deploy config (paste/upload JSON)
|
||||
- Consolidate pool UTXOs
|
||||
- Split the pool after the height gate
|
||||
- Cosign share claims and broadcast collected signatures
|
||||
|
||||
## Setup
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Contract docs live in the parent package: [`../docs/why-cashscript.md`](../docs/why-cashscript.md), [`../docs/infra-changes.md`](../docs/infra-changes.md), [`../docs/ui-deferred.md`](../docs/ui-deferred.md).
|
||||
|
||||
## Artifacts
|
||||
|
||||
Compiled CashScript artifacts are copied under `src/contracts/artifacts/`. After changing contracts in the parent package, run `npm run compile` there and re-copy the JSON artifacts into this app.
|
||||
|
||||
## Config
|
||||
|
||||
Paste or upload a deploy JSON matching `../config/deploy.mainnet.example.json` (also mirrored at `src/contracts/deploy.example.json`). Never put private keys in config.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Deploy
|
||||
This directory contains scripts for deploying the app to different platforms and blockchains.
|
||||
|
||||
## App Deployment
|
||||
|
||||
### Blockchains
|
||||
- Filecoin - The compiled app is uploaded to the Filecoin blockchain using [publish-filecoin.js](./publish-filecoin.js). Running this script requires a free API key from [web3.storage](https://web3.storage).
|
||||
- IPFS - The files are also pinned by the Pinata service using [publish-pinata.js](./publish-filecoin.js). Running this script requires a free JWT token from [Pinata](https://pinata.cloud).
|
||||
- Bitcoin Cash - The IPFS CID is written to the Bitcoin Cash blockchain with [publish-bch.js](/publish-bch.js) This creates an immutable, censorship-resistant, globally available, and secure pointer to the latest version of the app.
|
||||
|
||||
The above deployment scripts are orchestrated with [publish-main.js](`./publish-main.js`). This script is run by executing `npm run pub`.
|
||||
|
||||
### GitHub Pages
|
||||
The app can also be deployed to GitHub pages. This requires switching to the `gh-pages` branch and running the command `npm run pub:ghp`.
|
||||
|
||||
## Code Deployment
|
||||
The code in this repository is backed up to the [Radicle](https://radicle.network/get-started.html) network, as GitHub has been increasing its censorship of code. Find instructions for *consuming* the code in the [top-level README](../README.md). To learn how install Radicle on your own machine and collaborate on the code that way, check out [this research article](https://christroutner.github.io/trouts-blog/docs/censorship/radicle).
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
This script will write the CID for the current version of the app to an
|
||||
address on the BCH blockchain. This creates an immutable, censorship-resistant,
|
||||
globally available, and secure pointer to the latest version of the app.
|
||||
|
||||
The exported function expects an IPFS CID as input and returns a TXID for a
|
||||
BCH transaction.
|
||||
|
||||
This function expects this environtment variable to contain a WIF private key
|
||||
with BCH to write to the blockchain:
|
||||
- REACT_BOOTSTRAP_WEB3_SPA_WIF
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
// const BCHJS = require('@psf/bch-js')
|
||||
const BchWallet = require('minimal-slp-wallet/index')
|
||||
const BchMessageLib = require('bch-message-lib/index')
|
||||
|
||||
async function publishToBch (cid) {
|
||||
try {
|
||||
// Get the Filecoin token from the environment variable.
|
||||
const wif = process.env.REACT_BOOTSTRAP_WEB3_SPA_WIF
|
||||
if (!wif) {
|
||||
throw new Error(
|
||||
'WIF private key not detected. Get a private key from https://wallet.fullstack.cash and save it to the REACT_BOOTSTRAP_WEB3_SPA_WIF environment variable.'
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize libraries for working with BCH blockchain.
|
||||
// const bchjs = new BCHJS()
|
||||
const wallet = new BchWallet(wif, {
|
||||
interface: 'consumer-api'
|
||||
})
|
||||
await wallet.walletInfoPromise
|
||||
await wallet.initialize()
|
||||
const bchMsg = new BchMessageLib({ wallet })
|
||||
|
||||
// Publish the CID to the BCH blockchain.
|
||||
const hex = await bchMsg.memo.memoPush(cid, 'IPFS UPDATE')
|
||||
|
||||
// Broadcast the transaction to the network.
|
||||
const txid = await wallet.ar.sendTx(hex)
|
||||
// console.log(`BCH blockchain updated with new CID. TXID: ${txid}`)
|
||||
// console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
|
||||
|
||||
return txid
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = publishToBch
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
This library is used to publish the compiled app to Filecoin.
|
||||
The publishToFilecoin() function will upload the 'build' folder to Filecoin
|
||||
via the web3.storage API.
|
||||
The function will return an object that contains the CID of the uploaded
|
||||
directory, and a URL for loading the app in a browser.
|
||||
|
||||
In order to run this script, you must obtain an API key from web3.storage.
|
||||
That key should be saved to an environment variable named FILECOIN_TOKEN.
|
||||
*/
|
||||
|
||||
const { Web3Storage, getFilesFromPath } = require('web3.storage')
|
||||
const fs = require('fs')
|
||||
|
||||
async function publish () {
|
||||
try {
|
||||
const currentDir = `${__dirname}`
|
||||
// console.log(`Current directory: ${dir}`)
|
||||
const buildDir = `${currentDir}/../build`
|
||||
|
||||
// Get the Filecoin token from the environment variable.
|
||||
const filecoinToken = process.env.FILECOIN_TOKEN
|
||||
if (!filecoinToken) {
|
||||
throw new Error(
|
||||
'Filecoin token not detected. Get a token from https://web3.storage and save it to the FILECOIN_TOKEN environment variable.'
|
||||
)
|
||||
}
|
||||
|
||||
// Get a list of all the files to be uploaded.
|
||||
const fileAry = await getFileList(buildDir)
|
||||
// console.log(`fileAry: ${JSON.stringify(fileAry, null, 2)}`)
|
||||
|
||||
// Upload the files to Filecoin.
|
||||
const cid = await uploadToFilecoin(fileAry, filecoinToken)
|
||||
|
||||
// console.log('Content added to Filecoin with CID:', cid)
|
||||
// console.log(`https://${cid}.ipfs.dweb.link/`)
|
||||
|
||||
return cid
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
function getFileList (buildDir) {
|
||||
const fileAry = []
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readdir(buildDir, (err, files) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
files.forEach(file => {
|
||||
// console.log(file)
|
||||
fileAry.push(`${buildDir}/${file}`)
|
||||
})
|
||||
|
||||
return resolve(fileAry)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadToFilecoin (fileAry, token) {
|
||||
const storage = new Web3Storage({ token })
|
||||
|
||||
const files = []
|
||||
for (let i = 0; i < fileAry.length; i++) {
|
||||
const thisPath = fileAry[i]
|
||||
// console.log('thisPath: ', thisPath)
|
||||
|
||||
const pathFiles = await getFilesFromPath(thisPath)
|
||||
// console.log('pathFiles: ', pathFiles)
|
||||
|
||||
files.push(...pathFiles)
|
||||
}
|
||||
|
||||
console.log(`Uploading ${files.length} files. Please wait...`)
|
||||
const cid = await storage.put(files)
|
||||
|
||||
return cid
|
||||
}
|
||||
|
||||
module.exports = publish
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Bash shell script to publish the app to GitHub pages.
|
||||
# Ensure you are in the gh-pages branch.
|
||||
|
||||
#pwd
|
||||
#git checkout gh-pages
|
||||
#git merge master
|
||||
|
||||
npm run build
|
||||
cp -r build docs
|
||||
git add -A
|
||||
git commit -m "Updating GitHub page"
|
||||
git push
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
This is the main publish file that aggregates the other publish libraries
|
||||
and orchestrates them, so that one command can publish to different platforms.
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
const publishToFilecoin = require('./publish-filecoin')
|
||||
const publishToPinata = require('./publish-pinata')
|
||||
const publishToBch = require('./publish-bch')
|
||||
|
||||
async function publish () {
|
||||
try {
|
||||
// Publish to Filecoin
|
||||
const cid = await publishToFilecoin()
|
||||
console.log('Content added to Filecoin with CID:', cid)
|
||||
console.log(`https://${cid}.ipfs.dweb.link/`)
|
||||
|
||||
// Publish to Pinata
|
||||
await publishToPinata(cid)
|
||||
|
||||
// Public to BCH
|
||||
const txid = await publishToBch(cid)
|
||||
console.log(`\nBCH blockchain updated with new CID. TXID: ${txid}`)
|
||||
console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
|
||||
} catch (err) {
|
||||
console.error('Error while trying to publish app: ', err)
|
||||
}
|
||||
}
|
||||
publish()
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
This library will pin the app to Pinata. It expects a CID
|
||||
as input, which is the output of publish-filecoin.js.
|
||||
|
||||
Filecoin should be though of as cold-storage for data. It's very slow to
|
||||
retrieve. Pinata can be though of as RAM. It keeps content at the ready and
|
||||
fast to deliver. They are complimentary services.
|
||||
|
||||
In order to run this script, you must obtain an API key from pinata.cloud.
|
||||
That key should be saved to an environment variable named PINATA_JWT.
|
||||
*/
|
||||
|
||||
const axios = require('axios')
|
||||
|
||||
async function publishToPinata (cid) {
|
||||
// Get the Pinata token from the environment variable.
|
||||
const pinataToken = process.env.PINATA_JWT
|
||||
if (!pinataToken) {
|
||||
throw new Error(
|
||||
'Pinata JWT token not detected. Get a token from https://pinata.cloud and save it to the PINATA_JWT environment variable.'
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const data = JSON.stringify({
|
||||
hashToPin: cid,
|
||||
pinataMetadata: {
|
||||
name: 'react-bootstrap-web3-spa',
|
||||
keyvalues: {
|
||||
timestamp: now.toISOString()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const config = {
|
||||
method: 'post',
|
||||
url: 'https://api.pinata.cloud/pinning/pinByHash',
|
||||
headers: {
|
||||
Authorization: `Bearer ${pinataToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data
|
||||
}
|
||||
|
||||
const res = await axios(config)
|
||||
|
||||
console.log('\nCID pinned using Pinata:')
|
||||
console.log(res.data)
|
||||
}
|
||||
|
||||
module.exports = publishToPinata
|
||||
@@ -0,0 +1,28 @@
|
||||
# Developer Docs
|
||||
|
||||
This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation.
|
||||
|
||||
## Main Features of this App
|
||||
|
||||
- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control.
|
||||
- An easily customizable waiting modal component can be invoked while waiting for network calls to complete.
|
||||
- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain.
|
||||
- A 'server selection' dropdown allows the user to select from an array of redundent back end servers.
|
||||
- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control.
|
||||
|
||||
## File Layout
|
||||
|
||||
The top-level file layout of this app looks like this:
|
||||
|
||||
- App.js - the main application orchestates these child components:
|
||||
- GetRestUrl - retrieves the REST URL for the selected back-end web3 server from query paramenters in the URL.
|
||||
- LoadScripts - Loads the modal with a waiting spinner animation until the external script files are loaded.
|
||||
- NavMenu - the collapsible navigation menu
|
||||
- InitializedView & UnitializedView - the default Views that are displayed depending on the state of the app.
|
||||
- ServerSelect - allows the user to select a different web3 back end server.
|
||||
- Footer - Footer links
|
||||
|
||||
After initialization, the InitailizedView is displayed. This loads the AppBody, which is a wrapper for each View. Views are selected using the navigation menu. When one View is selected, the others are hidden.
|
||||
|
||||
## Loading of Wallet
|
||||
The wallet library [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is loaded at startup, and initialized with a web3 back end server. By default, the back-end server is free-bch.fullstack.cash. However, a list of back end servers provided by the [PSF](https://psfoundation.cash) are loaded into a drop-down from a GitHub
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
File diff suppressed because it is too large
Load Diff
Generated
+26810
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "psffpp-payments-ui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.0.0",
|
||||
"@chris.troutner/react-jdenticon": "1.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.7.2",
|
||||
"@fortawesome/react-fontawesome": "0.2.2",
|
||||
"axios": "0.27.2",
|
||||
"bch-message-lib": "2.2.1",
|
||||
"bch-token-sweep": "2.2.1",
|
||||
"bootstrap": "5.2.0",
|
||||
"cashscript": "^0.13.2",
|
||||
"qrcode.react": "4.2.0",
|
||||
"query-string": "7.1.1",
|
||||
"react": "19.0.0",
|
||||
"react-bootstrap": "2.10.7",
|
||||
"react-dom": "19.0.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router-dom": "7.1.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"use-local-storage-state": "19.5.0",
|
||||
"use-query-params": "1.2.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "echo 'no tests'",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"pub": "node deploy/publish-main.js",
|
||||
"pub:ghp": "./deploy/publish-gh-pages.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app",
|
||||
"globals": {
|
||||
"BigInt": "readonly"
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"minimal-slp-wallet": "5.13.1",
|
||||
"semantic-release": "24.2.3",
|
||||
"standard": "17.0.0",
|
||||
"web3.storage": "4.3.0"
|
||||
},
|
||||
"release": {
|
||||
"publish": [
|
||||
{
|
||||
"path": "@semantic-release/npm",
|
||||
"npmPublish": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PSFFPP Payments</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+220
@@ -0,0 +1,220 @@
|
||||
.header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.balance-spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link-inactive {
|
||||
color: gray !important;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
#address-switch {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Remove input number arrows Chrome, Safari, Edge, Opera */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Remove input number arrows Firefox */
|
||||
input[type=number] {
|
||||
--moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Markdown Content Styles */
|
||||
.markdown-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 3px solid #3b82f6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
margin-bottom: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: #f1f5f9;
|
||||
color: #e11d48;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: #1d4ed8;
|
||||
border-bottom-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markdown-content th, .markdown-content td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.markdown-content {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
.markdown-content h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.markdown-content h2 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.markdown-content p, .markdown-content li {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
This is an SPA that creates a template for future BCH web3 apps.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
|
||||
// Local libraries
|
||||
import './App.css'
|
||||
import LoadScripts from './components/load-scripts'
|
||||
import AsyncLoad from './services/async-load'
|
||||
import Footer from './components/footer'
|
||||
import NavMenu from './components/nav-menu'
|
||||
import useAppState from './hooks/state'
|
||||
import { UninitializedView, InitializedView } from './components/starter-views'
|
||||
|
||||
const sigleViewPaths = ['/profile', 'user-data']
|
||||
|
||||
function App (props) {
|
||||
// Load all the app state into a single object that can be passed to child
|
||||
// components.
|
||||
const appData = useAppState()
|
||||
|
||||
// Add a new line to the waiting modal.
|
||||
const addToModal = useCallback((inStr, appData) => {
|
||||
// console.log('addToModal() inStr: ', inStr)
|
||||
|
||||
appData.setModalBody(prevBody => {
|
||||
// console.log('prevBody: ', prevBody)
|
||||
prevBody.push(inStr)
|
||||
return prevBody
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isSignleView = useCallback(async () => {
|
||||
// Get current path
|
||||
const currentPath = window.location.pathname
|
||||
// Get hash from url
|
||||
const hash = window.location.hash
|
||||
const allowedPath = sigleViewPaths.find((val) => { return currentPath.match(val) })
|
||||
|
||||
if (hash === '#single-view' && allowedPath) {
|
||||
addToModal('Loading minimal-slp-wallet', appData)
|
||||
const asyncLoad = new AsyncLoad()
|
||||
if (!appData.wallet) {
|
||||
await asyncLoad.loadWalletLib()
|
||||
const walletTemp = await asyncLoad.initStarterWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
|
||||
appData.setWallet(walletTemp)
|
||||
// Get the BCH spot price
|
||||
addToModal('Getting BCH spot price in USD', appData)
|
||||
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
|
||||
}
|
||||
|
||||
// Update Modal State
|
||||
appData.setIsSingleView(true)
|
||||
appData.setHideSpinner(true)
|
||||
appData.setShowStartModal(false)
|
||||
appData.setDenyClose(false)
|
||||
|
||||
/* // Update the startup state.
|
||||
appData.setAsyncInitFinished(true)
|
||||
appData.setAsyncInitSucceeded(false) */
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [appData, addToModal])
|
||||
|
||||
/**
|
||||
* Run background process to get bch and slp balance.
|
||||
* Also update the background state process.
|
||||
* On error this function should trigger a info modal notifying the errors.
|
||||
*/
|
||||
const backgroundAsync = useCallback(async (asyncLoad, walletTemp) => {
|
||||
try {
|
||||
appData.setModalBody(['Getting BCH balance in background!.'])
|
||||
// Get Wallet Balance
|
||||
await asyncLoad.getWalletBchBalance(walletTemp, appData.updateBchWalletState, appData)
|
||||
// Update Background state
|
||||
appData.updateBackGroundInitState({ bchInitLoaded: true })
|
||||
|
||||
// Get SLP Balance
|
||||
appData.setModalBody(['Getting SLP tokens in background!.'])
|
||||
await asyncLoad.getSlpTokenBalances(walletTemp, appData.updateBchWalletState, appData)
|
||||
|
||||
// Update Background state
|
||||
appData.updateBackGroundInitState({ slpInitLoaded: true, asyncBackgroundFinished: true })
|
||||
} catch (err) {
|
||||
console.log('App.js backgroundAsync() error!', err)
|
||||
|
||||
appData.updateBackGroundInitState({ asyncBackgroundFinished: true })
|
||||
|
||||
addToModal(`Error: ${err.message}`, appData)
|
||||
addToModal('Try selecting a different back end server using the drop-down menu at the bottom of the app.\'', appData)
|
||||
|
||||
// Update Modal State
|
||||
appData.setHideSpinner(true)
|
||||
appData.setShowStartModal(true)
|
||||
appData.setDenyClose(false)
|
||||
|
||||
// Update the startup state.
|
||||
appData.setAsyncInitFinished(true)
|
||||
appData.setAsyncInitSucceeded(false)
|
||||
}
|
||||
}, [appData, addToModal])
|
||||
|
||||
/** Load all required data before component start. */
|
||||
useEffect(() => {
|
||||
async function asyncEffect () {
|
||||
const singleView = await isSignleView()
|
||||
console.log('asyncInitStarted: ', appData.asyncInitStarted)
|
||||
|
||||
if (!appData.asyncInitStarted && !singleView) {
|
||||
try {
|
||||
// Instantiate the async load object.
|
||||
const asyncLoad = new AsyncLoad()
|
||||
appData.setAsyncInitStarted(true)
|
||||
|
||||
addToModal('Loading minimal-slp-wallet', appData)
|
||||
appData.setDenyClose(true)
|
||||
|
||||
await asyncLoad.loadWalletLib()
|
||||
// console.log('Wallet: ', Wallet)
|
||||
|
||||
addToModal('Getting alternative servers', appData)
|
||||
const gistServers = await asyncLoad.getServers()
|
||||
appData.setServers(gistServers)
|
||||
// console.log('servers: ', servers)
|
||||
|
||||
addToModal('Initializing wallet', appData)
|
||||
console.log(`Initializing wallet with back end server ${appData.serverUrl}`)
|
||||
|
||||
const walletTemp = await asyncLoad.initWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
|
||||
appData.setWallet(walletTemp)
|
||||
// appData.updateBchWalletState({ walletObj: walletTemp.walletInfo, appData })
|
||||
|
||||
// Get the BCH spot price
|
||||
addToModal('Getting BCH spot price in USD', appData)
|
||||
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
|
||||
|
||||
// Update state
|
||||
appData.setShowStartModal(false)
|
||||
appData.setDenyClose(false)
|
||||
|
||||
// Update the startup state.
|
||||
appData.setAsyncInitFinished(true)
|
||||
appData.setAsyncInitSucceeded(true)
|
||||
console.log('App.js useEffect() startup finished successfully')
|
||||
|
||||
backgroundAsync(asyncLoad, walletTemp)
|
||||
// Get the BCH balance of the wallet.
|
||||
// addToModal('Getting BCH balance', appData)
|
||||
|
||||
// Get the SLP tokens held by the wallet.
|
||||
// addToModal('Getting SLP tokens', appData)
|
||||
} catch (err) {
|
||||
const errModalBody = [
|
||||
`Error: ${err.message}`,
|
||||
'Try selecting a different back end server using the drop-down menu at the bottom of the app.'
|
||||
]
|
||||
appData.setModalBody(errModalBody)
|
||||
|
||||
// Update Modal State
|
||||
appData.setHideSpinner(true)
|
||||
appData.setShowStartModal(true)
|
||||
appData.setDenyClose(false)
|
||||
|
||||
// Update the startup state.
|
||||
appData.setAsyncInitFinished(true)
|
||||
appData.setAsyncInitSucceeded(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
asyncEffect()
|
||||
}, [appData, addToModal, isSignleView, backgroundAsync])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadScripts />
|
||||
<div className='app-container'>
|
||||
<NavMenu appData={appData} />
|
||||
{/** Define View to show */}
|
||||
<div className='main-content'>
|
||||
{
|
||||
appData.showStartModal
|
||||
? (<UninitializedView appData={appData} />)
|
||||
: (<InitializedView menuState={appData.menuState} appData={appData} />)
|
||||
}
|
||||
</div>
|
||||
<Footer appData={appData} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import App from './App'
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div')
|
||||
ReactDOM.render(<App />, div)
|
||||
ReactDOM.unmountComponentAtNode(div)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Component for looking up the balance of a BCH address.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Form, Button, Spinner } from 'react-bootstrap'
|
||||
|
||||
function GetBalance (props) {
|
||||
const { wallet } = props
|
||||
|
||||
// State
|
||||
const [balance, setBalance] = useState('')
|
||||
const [textInput, setTextInput] = useState('')
|
||||
|
||||
// Button click handler
|
||||
const handleGetBalance = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
// Exit on invalid input
|
||||
if (!textInput) return
|
||||
if (!textInput.includes('bitcoincash:')) return
|
||||
|
||||
setBalance(
|
||||
<div className='balance-spinner-container'>
|
||||
<span>Retrieving balance...</span>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)
|
||||
|
||||
const balance = await wallet.getBalance({ bchAddress: textInput })
|
||||
console.log('balance: ', balance)
|
||||
|
||||
const bchBalance = wallet.bchjs.BitcoinCash.toBitcoinCash(balance)
|
||||
|
||||
setBalance(`Balance: ${balance} sats, ${bchBalance} BCH`)
|
||||
} catch (err) {
|
||||
setBalance(<p><b>Error</b>: {`${err.message}`}</p>)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col className='text-break' style={{ textAlign: 'center' }}>
|
||||
<Form onSubmit={handleGetBalance}>
|
||||
<Form.Group className='mb-3' controlId='formBasicEmail'>
|
||||
<Form.Label>Enter any BCH address to query its balance on the blockchain.</Form.Label>
|
||||
<Form.Control type='text' placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' onChange={e => setTextInput(e.target.value)} />
|
||||
</Form.Group>
|
||||
|
||||
<Button variant='primary' onClick={handleGetBalance}>
|
||||
Check Balance
|
||||
</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
{balance}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GetBalance
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
This card displays the users balance in BCH.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Container, Row, Col, Card, Spinner } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCoins } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const BalanceCard = (props) => {
|
||||
const { appData } = props
|
||||
const [sats, setSats] = useState('')
|
||||
const [bchBalance, setbchBalance] = useState('')
|
||||
const [usdBalance, setusdBalance] = useState('')
|
||||
|
||||
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
|
||||
|
||||
// Calculate balances if wallet is successfully loaded!
|
||||
useEffect(() => {
|
||||
try {
|
||||
const bchjs = appData.wallet.bchjs
|
||||
if (bchjs && appData.asyncInitSucceeded) {
|
||||
const sats = appData.bchWalletState.bchBalance
|
||||
const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats)
|
||||
const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice)
|
||||
|
||||
setSats(sats)
|
||||
setbchBalance(bchBalance)
|
||||
setusdBalance(usdBalance)
|
||||
}
|
||||
} catch (error) {
|
||||
// console.warn(error)
|
||||
}
|
||||
}, [appData])
|
||||
// Background bch data loaded finished
|
||||
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
|
||||
const backgroundDataError = !bchInitLoaded && asyncBackgroundFinished
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2><FontAwesomeIcon icon={faCoins} size='lg' /> Balance</h2>
|
||||
</Card.Title>
|
||||
<br />
|
||||
|
||||
{bchInitLoaded && (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<b>USD</b>: ${usdBalance}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<b>BCH</b>: {bchBalance}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<b>Satoshis</b>: {sats}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>)}
|
||||
{backgroundDataError && (
|
||||
<Container>
|
||||
<span style={{ color: 'red' }}>Balance could not be loaded!</span>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!backgroundDataLoaded && appData.asyncInitSucceeded && (
|
||||
<div className='balance-spinner-container'>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BalanceCard
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
This View allows sending and receiving of BCH
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local libraries
|
||||
import RefreshBchBalanceButton from './refresh-bch-balance-button'
|
||||
import SendCard from './send-card'
|
||||
import BalanceCard from './balance-card'
|
||||
import ReceiveCard from './receive-card'
|
||||
|
||||
// Working array for storing modal output.
|
||||
// this.modalBody = []
|
||||
|
||||
function BchSend ({ appData }) {
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<RefreshBchBalanceButton
|
||||
appData={appData}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} style={{ textAlign: 'right' }}>
|
||||
<a href='https://youtu.be/KN1ZMWoLoGs' target='_blank' rel='noreferrer'>
|
||||
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<BalanceCard appData={appData} />
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<SendCard
|
||||
appData={appData}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<ReceiveCard appData={appData} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BchSend
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
This card displays the users BCH and SLP address and QR code
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Form } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWallet } from '@fortawesome/free-solid-svg-icons'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
|
||||
const ReceiveCard = ({ appData }) => {
|
||||
const [addrSwitch, setAddrSwitch] = useState(false)
|
||||
const [displayCopyMsg, setDisplayCopyMsg] = useState(false)
|
||||
|
||||
// Determine which address to display
|
||||
const addrToDisplay = !addrSwitch
|
||||
? appData.bchWalletState.cashAddress
|
||||
: appData.bchWalletState.slpAddress
|
||||
|
||||
// Copy the selected address to the clipboard when the QR image is clicked
|
||||
const handleCopyAddress = async (value) => {
|
||||
appData.appUtil.copyToClipboard(value)
|
||||
|
||||
// Display the copied message
|
||||
setDisplayCopyMsg(true)
|
||||
|
||||
// Clear the copied message after some time
|
||||
setTimeout(() => {
|
||||
setDisplayCopyMsg(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Event handler for address switch toggle
|
||||
const handleAddrSwitchToggle = (event) => {
|
||||
setAddrSwitch(event.target.checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Body style={{ textAlign: 'center' }}>
|
||||
<Card.Title>
|
||||
<h2><FontAwesomeIcon icon={faWallet} size='lg' /> Receive</h2>
|
||||
</Card.Title>
|
||||
<br />
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col style={{ color: 'green', marginBottom: '20px' }}>
|
||||
{displayCopyMsg ? 'Copied' : null}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<QRCodeSVG
|
||||
style={{ cursor: 'pointer' }}
|
||||
className='qr-code'
|
||||
value={addrToDisplay}
|
||||
size={256}
|
||||
fgColor='#333'
|
||||
onClick={() => { handleCopyAddress(addrToDisplay) }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col style={{ marginTop: '20px' }}>
|
||||
<p>{addrToDisplay}</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={4} />
|
||||
<Col xs={4}>
|
||||
<Form>
|
||||
<Form.Check
|
||||
type='switch'
|
||||
id='address-switch'
|
||||
onChange={e => handleAddrSwitchToggle(e)}
|
||||
/>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col xs={4} />
|
||||
</Row>
|
||||
</Container>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReceiveCard
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
This library exports a RefreshBalance functional Component and a
|
||||
refreshBalance() function.
|
||||
The RefreshBalance Component is rendered as a hidden Waiting modal.
|
||||
When the refreshBalance() function is called, it causes the modal to
|
||||
appear while the wallet balance is updated. Once updated, the modal is hidden
|
||||
again.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
// Local libraries
|
||||
import WaitingModal from '../../waiting-modal'
|
||||
|
||||
export default function RefreshBchBalance (props) {
|
||||
// Dependency injections of props
|
||||
const { ref } = props
|
||||
|
||||
// State
|
||||
const [showWaitingModal, setShowWaitingModal] = useState(false)
|
||||
const [modalBody, setModalBody] = useState([])
|
||||
const [hideSpinner] = useState(false)
|
||||
|
||||
// Add a new line to the waiting modal.
|
||||
const addToModal = useCallback((inStr) => {
|
||||
// console.log('addToModal() inStr: ', inStr)
|
||||
setModalBody(prevBody => {
|
||||
// console.log('prevBody: ', prevBody)
|
||||
prevBody.push(inStr)
|
||||
return prevBody
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Update the balance of the wallet.
|
||||
const handleRefreshBalance = useCallback(async (appData) => {
|
||||
try {
|
||||
setModalBody([])
|
||||
|
||||
// Throw up the waiting modal
|
||||
setShowWaitingModal(true)
|
||||
|
||||
addToModal('Updating wallet balance...')
|
||||
|
||||
// Get handles on app data.
|
||||
const walletState = appData.bchWalletState
|
||||
const cashAddr = appData.bchWalletState.cashAddress
|
||||
const wallet = appData.wallet
|
||||
|
||||
// Get the latest balance of the wallet.
|
||||
const newBalance = await wallet.getBalance({ bchAddress: cashAddr })
|
||||
|
||||
// if bchInitLoaded is 'false', them set as true , to show the new balance.
|
||||
appData.updateBackGroundInitState({ bchInitLoaded: true })
|
||||
|
||||
addToModal('Updating BCH per USD price...')
|
||||
const bchUsdPrice = await wallet.getUsd()
|
||||
|
||||
// Update the wallet state.
|
||||
walletState.bchBalance = newBalance
|
||||
walletState.bchUsdPrice = bchUsdPrice
|
||||
appData.updateBchWalletState({ walletState, appData })
|
||||
|
||||
setShowWaitingModal(false)
|
||||
setModalBody([])
|
||||
} catch (err) {
|
||||
console.error('Error while trying to update BCH balance: ', err)
|
||||
|
||||
addToModal([`Error: ${err.message}`])
|
||||
setShowWaitingModal(false)
|
||||
}
|
||||
}, [addToModal])
|
||||
|
||||
// add a ref to the handleRefreshBalance function
|
||||
// This is used to call the function from the parent component.
|
||||
useEffect(() => {
|
||||
if (ref && !ref.current) ref.current = { handleRefreshBalance }
|
||||
}, [ref, handleRefreshBalance])
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
{showWaitingModal && (
|
||||
<WaitingModal
|
||||
heading='Refreshing BCH Balance'
|
||||
body={modalBody}
|
||||
hideSpinner={hideSpinner}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
This component is displayed as a button. When clicked, it loads the
|
||||
RefreshBchBalance component, which renders a waiting modal while the wallet
|
||||
balance is refreshed.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useRef } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faRedo } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local libraries
|
||||
import RefreshBchBalance from './refresh-balance'
|
||||
|
||||
function RefreshBchBalanceButton (props) {
|
||||
// Dependency injections of props
|
||||
const appData = props.appData
|
||||
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
|
||||
|
||||
// Background bch data loaded finished
|
||||
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
|
||||
|
||||
// Child function references
|
||||
const refreshBchBalanceRef = useRef()
|
||||
|
||||
// Update the balance of the wallet.
|
||||
async function handleButtonRefreshBalance (appData) {
|
||||
// Call the child function
|
||||
refreshBchBalanceRef.current.handleRefreshBalance(appData)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant='success' onClick={() => { handleButtonRefreshBalance(appData) }} disabled={!backgroundDataLoaded}>
|
||||
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
|
||||
</Button>
|
||||
|
||||
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshBchBalanceButton
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
This component controls sending of BCH.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Container, Row, Col, Card, Form, Button } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPaperPlane, faPaste, faRandom } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local libraries
|
||||
import WaitingModal from '../../waiting-modal'
|
||||
import RefreshBchBalance from './refresh-balance'
|
||||
|
||||
function SendCard (props) {
|
||||
// Dependency injection through props
|
||||
const appData = props.appData
|
||||
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
|
||||
|
||||
// Modal State
|
||||
const [modalBody, setModalBody] = useState([])
|
||||
const [hideSpinner, setHideSpinner] = useState(false)
|
||||
const [hideWaitingModal, setHideWaitingModal] = useState(true)
|
||||
const [hideModal, setHideModal] = useState(true)
|
||||
|
||||
// Form State
|
||||
const [bchAddr, setBchAddr] = useState('')
|
||||
const [amountStr, setAmountStr] = useState('')
|
||||
const [amountUnits, setAmountUnits] = useState('USD')
|
||||
const [oppositeUnits, setOppositeUnits] = useState('BCH')
|
||||
const [oppositeQty, setOppositeQty] = useState(0)
|
||||
|
||||
// Background bch data loaded finished
|
||||
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
|
||||
|
||||
// Child function references
|
||||
const refreshBchBalanceRef = useRef()
|
||||
|
||||
// Update the balance of the wallet.
|
||||
async function handleButtonRefreshBalance (appData) {
|
||||
// Call the child function
|
||||
refreshBchBalanceRef.current.handleRefreshBalance(appData)
|
||||
}
|
||||
|
||||
// Encapsulate the state for this component into a single object that can
|
||||
// be passed around to subfunctions and subcomponents.
|
||||
const sendCardData = {
|
||||
modalBody,
|
||||
setModalBody,
|
||||
hideSpinner,
|
||||
setHideSpinner,
|
||||
hideWaitingModal,
|
||||
setHideWaitingModal,
|
||||
hideModal,
|
||||
setHideModal,
|
||||
bchAddr,
|
||||
setBchAddr,
|
||||
amountStr,
|
||||
setAmountStr,
|
||||
amountUnits,
|
||||
setAmountUnits,
|
||||
oppositeUnits,
|
||||
setOppositeUnits,
|
||||
oppositeQty,
|
||||
setOppositeQty
|
||||
}
|
||||
|
||||
// This function is called when the modal is closed.
|
||||
function onModalClose () {
|
||||
sendCardData.setHideModal(true)
|
||||
|
||||
handleButtonRefreshBalance(appData)
|
||||
}
|
||||
|
||||
async function pasteFromClipboard () {
|
||||
try {
|
||||
const addr = await appData.appUtil.readFromClipboard()
|
||||
sendCardData.setBchAddr(addr)
|
||||
} catch (err) {
|
||||
// Browser implementation. Exit quietly.
|
||||
}
|
||||
}
|
||||
|
||||
// This is an on-change event handler that updates the amount calculated in
|
||||
// both BCH and USD as the user types.
|
||||
function handleUpdateAmount (inObj = {}) {
|
||||
try {
|
||||
const { event, appData, sendCardData } = inObj
|
||||
|
||||
// Update the state of the text box.
|
||||
let amountStr = event.target.value
|
||||
sendCardData.setAmountStr(amountStr)
|
||||
if (!amountStr) amountStr = '0'
|
||||
|
||||
// Convert the string to a number.
|
||||
const amountQty = parseFloat(amountStr)
|
||||
|
||||
const bchUsdPrice = appData.bchWalletState.bchUsdPrice
|
||||
const bchjs = appData.wallet.bchjs
|
||||
|
||||
// Initialize local variables
|
||||
let oppositeQty = 0
|
||||
// const amountUsd = 0
|
||||
// const amountBch = 0
|
||||
|
||||
// Calculate the amount in the opposite units.
|
||||
const currentUnit = sendCardData.amountUnits
|
||||
if (currentUnit.includes('USD')) {
|
||||
// Convert USD to BCH
|
||||
oppositeQty = bchjs.Util.floor8(amountQty / bchUsdPrice)
|
||||
// amountUsd = amountQty
|
||||
// amountBch = oppositeQty
|
||||
} else {
|
||||
// Convert BCH to USD
|
||||
oppositeQty = bchjs.Util.floor2(amountQty * bchUsdPrice)
|
||||
// amountUsd = oppositeQty
|
||||
// amountBch = amountQty
|
||||
}
|
||||
|
||||
// Update app state
|
||||
sendCardData.setOppositeQty(oppositeQty)
|
||||
} catch (err) {
|
||||
/* exit quietly */
|
||||
console.log('Error: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
// This is a click event handler that toggles the units between BCH and USD.
|
||||
function handleSwitchUnits ({ sendCardData }) {
|
||||
// Toggle the unit
|
||||
let newUnit = ''
|
||||
let oppositeUnits = ''
|
||||
const oldUnit = sendCardData.amountUnits
|
||||
if (oldUnit.includes('USD')) {
|
||||
newUnit = 'BCH'
|
||||
oppositeUnits = 'USD'
|
||||
} else {
|
||||
newUnit = 'USD'
|
||||
oppositeUnits = 'BCH'
|
||||
}
|
||||
|
||||
// Clear the Amount text box
|
||||
sendCardData.setAmountStr('')
|
||||
sendCardData.setOppositeQty(0)
|
||||
|
||||
// Persist the new units.
|
||||
sendCardData.setAmountUnits(newUnit)
|
||||
sendCardData.setOppositeUnits(oppositeUnits)
|
||||
}
|
||||
|
||||
// Add a new line to the waiting modal.
|
||||
function addToModal (inStr, sendCardData) {
|
||||
sendCardData.setModalBody(prevBody => {
|
||||
prevBody.push(inStr)
|
||||
return prevBody
|
||||
})
|
||||
}
|
||||
|
||||
// Send BCH based to the address in the form, and the amount specified in the
|
||||
// form.
|
||||
async function handleSendBch ({ sendCardData, appData }) {
|
||||
console.log('Sending BCH')
|
||||
try {
|
||||
// Clear the modal body
|
||||
sendCardData.setModalBody([])
|
||||
sendCardData.setHideSpinner(false)
|
||||
|
||||
// Open the modal
|
||||
sendCardData.setHideModal(false)
|
||||
|
||||
let amountBch
|
||||
if (sendCardData.amountUnits === 'USD') {
|
||||
amountBch = sendCardData.oppositeQty
|
||||
} else {
|
||||
amountBch = parseFloat(sendCardData.amountStr)
|
||||
}
|
||||
console.log('amountBch: ', amountBch)
|
||||
|
||||
if (amountBch < 0.00000546) throw new Error('Trying to send less than dust.')
|
||||
|
||||
let bchAddr = sendCardData.bchAddr
|
||||
let infoStr = `Sending ${amountBch} BCH ($${sendCardData.amountUsd} USD) to ${bchAddr}`
|
||||
console.log(infoStr)
|
||||
|
||||
// Update modal
|
||||
addToModal('Preparing to send bch...', sendCardData)
|
||||
|
||||
const wallet = appData.wallet
|
||||
const bchjs = wallet.bchjs
|
||||
|
||||
// If the address is an SLP address, convert it to a cash address.
|
||||
if (bchAddr.includes('simpleledger:')) {
|
||||
bchAddr = bchjs.SLP.Address.toCashAddress(bchAddr)
|
||||
}
|
||||
|
||||
// Convert the BCH to satoshis
|
||||
const sats = bchjs.BitcoinCash.toSatoshi(amountBch)
|
||||
|
||||
// Update the wallets UTXOs
|
||||
infoStr = 'Updating UTXOs...'
|
||||
console.log(infoStr)
|
||||
addToModal(infoStr, sendCardData)
|
||||
await wallet.getUtxos()
|
||||
|
||||
const receivers = [{
|
||||
address: bchAddr,
|
||||
amountSat: sats
|
||||
}]
|
||||
const txid = await wallet.send(receivers)
|
||||
|
||||
// Display TXID
|
||||
infoStr = `txid: ${txid}`
|
||||
// console.log(infoStr)
|
||||
// modalBody.push(infoStr)
|
||||
addToModal(infoStr, sendCardData)
|
||||
|
||||
// Link to block explorer
|
||||
const explorerUrl = `https://blockchair.com/bitcoin-cash/transaction/${txid}`
|
||||
const explorerLink = (<a href={`${explorerUrl}`} target='_blank' rel='noreferrer'>Block Explorer</a>)
|
||||
// modalBody.push(explorerLink)
|
||||
addToModal(explorerLink, sendCardData)
|
||||
|
||||
sendCardData.setHideSpinner(true)
|
||||
sendCardData.setBchAddr('')
|
||||
sendCardData.setAmountStr('')
|
||||
} catch (err) {
|
||||
console.log('Error in handleSendBch(): ', err)
|
||||
|
||||
sendCardData.setModalBody([`Error: ${err.message}`])
|
||||
sendCardData.setHideSpinner(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
hideModal
|
||||
? null
|
||||
: (<WaitingModal
|
||||
heading='Sending BCH'
|
||||
body={modalBody}
|
||||
hideSpinner={hideSpinner}
|
||||
closeFunc={onModalClose}
|
||||
closeModalData={{ appData, sendCardData }}
|
||||
/>)
|
||||
}
|
||||
|
||||
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send</h2>
|
||||
</Card.Title>
|
||||
<br />
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<b>BCH Address:</b>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={12} style={{ textAlign: 'center' }}>
|
||||
<Form onSubmit={(e) => e.preventDefault()}>
|
||||
<Form.Group controlId='formBasicEmail' style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Control
|
||||
style={{ marginRight: '1rem' }}
|
||||
type='text'
|
||||
placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
onChange={e => setBchAddr(e.target.value)}
|
||||
value={bchAddr}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={faPaste}
|
||||
size='lg'
|
||||
onClick={(e) => pasteFromClipboard()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<b>Amount:</b>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<Form style={{ paddingBottom: '10px' }} onSubmit={(e) => { e.preventDefault(); handleSendBch({ sendCardData, appData }) }}>
|
||||
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
|
||||
<Form.Control
|
||||
type='number'
|
||||
onChange={(event) => handleUpdateAmount({ event, appData, sendCardData })}
|
||||
value={amountStr}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<strong>Units</strong> : {amountUnits}
|
||||
<FontAwesomeIcon
|
||||
style={{ cursor: 'pointer', marginLeft: '5px' }}
|
||||
icon={faRandom}
|
||||
size='lg'
|
||||
onClick={(e) => handleSwitchUnits({ sendCardData, appData })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} style={{ textAlign: 'right' }}>
|
||||
<strong>{oppositeUnits}</strong> : {oppositeQty}
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<Button onClick={(e) => handleSendBch({ sendCardData, appData })} disabled={!backgroundDataLoaded}>Send</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
</Container>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SendCard
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
This Card component is used to clear the Local Storage and reset the wallet.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const WalletClear = (props) => {
|
||||
const { removeLocalStorageItem } = props.appData
|
||||
|
||||
// Delete wallet data from Local Storage and reload the app.
|
||||
const handleClearLocalStorage = () => {
|
||||
console.log('Deleting wallet and reloading page.')
|
||||
// Delete the mnemonic from Local Storage
|
||||
removeLocalStorageItem('mnemonic')
|
||||
// Reload the app.
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2>
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} />{' '}
|
||||
<span>Clear Local Storage</span>
|
||||
</h2>
|
||||
</Card.Title>
|
||||
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
Clicking the button below will clear the Local Storage, which
|
||||
will reload the app with a newly created wallet.
|
||||
<br />
|
||||
<b>
|
||||
Be sure to write down your 12-word mnemonic to back
|
||||
up your wallet before clicking the button!
|
||||
</b>.
|
||||
<br /><br />
|
||||
<Button variant='danger' onClick={handleClearLocalStorage}>
|
||||
Delete Wallet
|
||||
</Button>
|
||||
</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WalletClear
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
This component is visually represented with a copy icon. A wallet property
|
||||
is passed as a prop. When clicked, the wallet property is copied to the
|
||||
system clipboard.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCopy } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const CopyOnClick = (props) => {
|
||||
// State
|
||||
const [iconVis, setIconVis] = useState(true)
|
||||
// Props
|
||||
const { appData, walletProp, value } = props
|
||||
// App Util
|
||||
const { appUtil } = appData
|
||||
|
||||
// Function to copy the value to the clipboard.
|
||||
const handleCopyToClipboard = useCallback(async (event) => {
|
||||
appUtil.copyToClipboard(value)
|
||||
|
||||
// hide icon in order to show the copied message
|
||||
setIconVis(false)
|
||||
|
||||
// restart icon visibility after 1 second
|
||||
setTimeout(function () {
|
||||
setIconVis(true)
|
||||
}, 1000)
|
||||
}, [value, appUtil])
|
||||
|
||||
return (
|
||||
<>
|
||||
{iconVis && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCopy} size='lg'
|
||||
id={`${walletProp}-icon`}
|
||||
onClick={(e) => handleCopyToClipboard(e)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
{!iconVis && (
|
||||
<span
|
||||
id={`${walletProp}-word`}
|
||||
style={{ color: 'green' }}
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyOnClick
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
This component allows the user to import a new wallet using a 12-word mnemonic.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useCallback } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faFileExport, faPaste } from '@fortawesome/free-solid-svg-icons'
|
||||
// import { Clipboard } from '@capacitor/clipboard'
|
||||
|
||||
const WalletImport = (props) => {
|
||||
const [newMnemonic, setNewMnemonic] = React.useState('')
|
||||
const { appData } = props
|
||||
|
||||
// Load mnemonic from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const mnemonic = await appData.appUtil.readFromClipboard()
|
||||
setNewMnemonic(mnemonic)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input change for mnemonic
|
||||
const handleImportMnemonic = async (event) => {
|
||||
const inputStr = event.target.value
|
||||
const formattedInput = inputStr.toLowerCase()
|
||||
setNewMnemonic(formattedInput)
|
||||
}
|
||||
|
||||
// Ensure the mnemonic is valid. If it is, then replace the current mnemonic
|
||||
// in LocalStorage and reload the page.
|
||||
const handleImportWallet = useCallback(async (event) => {
|
||||
try {
|
||||
const mnemonic = newMnemonic
|
||||
const wallet = appData.wallet
|
||||
const bchjs = wallet.bchjs
|
||||
|
||||
// Verify the mnemonic is valid.
|
||||
const isValid = bchjs.Mnemonic.validate(mnemonic, bchjs.Mnemonic.wordLists().english)
|
||||
if (isValid.includes('is not in wordlist')) {
|
||||
console.log('Mnemonic is NOT valid')
|
||||
} else {
|
||||
console.log('Mnemonic is valid')
|
||||
}
|
||||
|
||||
// Replace the old mnemonic in LocalStorage with the new one.
|
||||
appData.updateLocalStorage({ mnemonic })
|
||||
// Reload the app.
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.warn('Error importing wallet: ', error)
|
||||
}
|
||||
}, [newMnemonic, appData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2>
|
||||
<FontAwesomeIcon icon={faFileExport} />{' '}
|
||||
<span>Import Wallet</span>
|
||||
</h2>
|
||||
</Card.Title>
|
||||
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
Enter a 12 word mnemonic below to import your wallet into
|
||||
this app. The app will reload and use the new mnemonic.
|
||||
</Card.Text>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col xs={12} className='text-break' style={{ textAlign: 'center' }}>
|
||||
<Form onSubmit={(e) => e.preventDefault()}>
|
||||
<Form.Group className='mb-3' controlId='formImportWallet' style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Control
|
||||
style={{ margin: '1rem' }}
|
||||
type='text'
|
||||
value={newMnemonic}
|
||||
onChange={handleImportMnemonic}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faPaste}
|
||||
size='lg'
|
||||
onClick={(e) => pasteFromClipboard(e)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<Button variant='primary' onClick={handleImportWallet}>
|
||||
Import
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<br />
|
||||
</Container>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WalletImport
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
This component controlls the Wallet View.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local Libraries
|
||||
import WebWalletWarning from './warning'
|
||||
import WalletSummary from './wallet-summary'
|
||||
import WalletClear from './clear-wallet'
|
||||
import WalletImport from './import-wallet'
|
||||
import OptimizeWallet from './optimize-wallet'
|
||||
|
||||
function BchWallet (props) {
|
||||
// Dependency injection through props
|
||||
const appData = props.appData
|
||||
console.log('appData: ', appData)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'right' }}>
|
||||
<a href='https://youtu.be/0R00cppN0fA' target='_blank' rel='noreferrer'>
|
||||
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
<WebWalletWarning />
|
||||
<br />
|
||||
<WalletSummary appData={appData} />
|
||||
<br />
|
||||
<WalletClear appData={appData} />
|
||||
<br />
|
||||
<WalletImport appData={appData} />
|
||||
<br />
|
||||
<OptimizeWallet appData={appData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BchWallet
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
This component allows the user to optimize their wallet by consolidating
|
||||
UTXOs. This speeds up all the network calls and results in an improved UX.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import WaitingModal from '../../waiting-modal'
|
||||
|
||||
function OptimizeWallet (props) {
|
||||
// State
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [modalBody, setModalBody] = useState([])
|
||||
const [hideSpinner, setHideSpinner] = useState(false)
|
||||
const [denyClose, setDenyClose] = useState(false)
|
||||
|
||||
// Get props values
|
||||
const { wallet } = props.appData
|
||||
|
||||
// Optimize wallet
|
||||
const handleOptimize = async () => {
|
||||
console.log('Optimize Wallet button clicked.')
|
||||
// Show waiting modal
|
||||
setShowModal(true)
|
||||
setModalBody(['Optimizing wallet...'])
|
||||
setDenyClose(true)
|
||||
|
||||
// Optimize wallet
|
||||
await wallet.optimize()
|
||||
|
||||
// Show success modal
|
||||
setShowModal(true)
|
||||
setModalBody(['Your wallet has been optimized!'])
|
||||
setDenyClose(false)
|
||||
setHideSpinner(true)
|
||||
|
||||
try {
|
||||
// Get all UTXOs in the wallet
|
||||
const utxos = wallet.utxos.utxoStore
|
||||
console.log('utxos: ', utxos)
|
||||
|
||||
// Add up all the UTXOs
|
||||
const bchUtxoCnt = utxos.bchUtxos.length
|
||||
let fungibleUtxoCnt = utxos.slpUtxos.type1.tokens.length
|
||||
if (!fungibleUtxoCnt) fungibleUtxoCnt = 0
|
||||
let nftUtxoCnt = utxos.slpUtxos.nft.length
|
||||
if (!nftUtxoCnt) nftUtxoCnt = 0
|
||||
const totalUtxos = bchUtxoCnt + fungibleUtxoCnt + nftUtxoCnt
|
||||
console.log(`bchUtxoCnt: ${bchUtxoCnt}, fungibleUtxoCnt: ${fungibleUtxoCnt}, nftUtxoCnt: ${nftUtxoCnt}`)
|
||||
console.log(`total UTXO count: ${totalUtxos}`)
|
||||
|
||||
if (totalUtxos > 10) {
|
||||
const newModalBody = [
|
||||
'Your wallet has been optimized!',
|
||||
'Your wallet still has more than 10 UTXOs. Increased numbers of UTXOs slow down performance. If you have several tokens in your wallet, it is recommended that you store them in a paper wallet. Here is a video explaining how to do that:'
|
||||
]
|
||||
|
||||
newModalBody.push(<a href='https://youtu.be/mRniqpgWdjg' target='_blank' rel='noreferrer'>Video: How to Store SLP Tokens on a Paper Wallet</a>)
|
||||
newModalBody.push(<a href='https://paperwallet.fullstack.cash/' target='_blank' rel='noreferrer'>Generate a Paper Wallet</a>)
|
||||
|
||||
setModalBody(newModalBody)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Error while trying to count total number of UTXOs: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2>Optimize Wallet</h2>
|
||||
</Card.Title>
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
|
||||
Clicking the button below will optimize your wallet and make it
|
||||
function faster.
|
||||
<br /><br />
|
||||
<i>How it works</i>: By consolidating
|
||||
as many UTXOs in your wallet as possible, it reduces the total
|
||||
number of UTXOs in your wallet. Fewer UTXOs in your wallet make
|
||||
all network calls faster, and results in an improved user experience.
|
||||
<br /><br />
|
||||
<Button variant='primary' onClick={handleOptimize}>
|
||||
Optimize Wallet
|
||||
</Button>
|
||||
</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
{showModal && (
|
||||
<WaitingModal
|
||||
heading='Optimizing Wallet'
|
||||
body={modalBody}
|
||||
hideSpinner={hideSpinner}
|
||||
denyClose={denyClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OptimizeWallet
|
||||
@@ -0,0 +1,4 @@
|
||||
.blurred {
|
||||
filter: blur(6px);
|
||||
-webkit-filter: blur(6px);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
This component displays a summary of the wallet.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWallet, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
|
||||
// import { Clipboard } from '@capacitor/clipboard'
|
||||
|
||||
// Local libraries
|
||||
import './wallet-summary.css'
|
||||
import CopyOnClick from './copy-on-click'
|
||||
|
||||
function WalletSummary (props) {
|
||||
// Props
|
||||
const appData = props.appData
|
||||
|
||||
const bchWalletState = appData.bchWalletState
|
||||
console.log('wallet summary state: ', bchWalletState)
|
||||
|
||||
// State
|
||||
const [blurredMnemonic, setBlurredMnemonic] = useState(true)
|
||||
const [blurredPrivateKey, setBlurredPrivateKey] = useState(true)
|
||||
|
||||
// Encapsulate component state into an object that can be passed to child functions
|
||||
const walletSummaryData = {
|
||||
blurredMnemonic,
|
||||
setBlurredMnemonic,
|
||||
blurredPrivateKey,
|
||||
setBlurredPrivateKey
|
||||
}
|
||||
|
||||
// Eye icon state
|
||||
const eyeIcon = {
|
||||
mnemonic: blurredMnemonic ? faEyeSlash : faEye,
|
||||
privateKey: blurredPrivateKey ? faEyeSlash : faEye
|
||||
}
|
||||
|
||||
// Toggle the state of blurring for the mnemonic
|
||||
const toggleMnemonicBlur = (inObj = {}) => {
|
||||
try {
|
||||
const { walletSummaryData } = inObj
|
||||
|
||||
// toggle the state of blurring
|
||||
const blurredState = walletSummaryData.blurredMnemonic
|
||||
walletSummaryData.setBlurredMnemonic(!blurredState)
|
||||
} catch (error) {
|
||||
console.error('Error toggling mnemonic blur: ', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the state of blurring for the private key
|
||||
const togglePrivateKeyBlur = (inObj = {}) => {
|
||||
try {
|
||||
const { walletSummaryData } = inObj
|
||||
|
||||
// toggle the state of blurring
|
||||
const blurredState = walletSummaryData.blurredPrivateKey
|
||||
walletSummaryData.setBlurredPrivateKey(!blurredState)
|
||||
} catch (error) {
|
||||
console.error('Error toggling private key blur: ', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2>
|
||||
<FontAwesomeIcon icon={faWallet} />{' '}
|
||||
<span>My Wallet</span>
|
||||
</h2>
|
||||
</Card.Title>
|
||||
<Container>
|
||||
<Row style={{ padding: '25px' }}>
|
||||
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>Mnemonic:</b> <span className={blurredMnemonic ? 'blurred' : null}>{bchWalletState.mnemonic}</span>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<FontAwesomeIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.mnemonic}
|
||||
size='lg'
|
||||
onClick={() => toggleMnemonicBlur({ walletSummaryData })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='mnemonic' appData={appData} value={bchWalletState.mnemonic} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
|
||||
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>Private Key:</b> <span className={blurredPrivateKey ? 'blurred' : null}>{bchWalletState.privateKey}</span>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<FontAwesomeIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.privateKey}
|
||||
size='lg'
|
||||
onClick={() => togglePrivateKeyBlur({ walletSummaryData })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='privateKey' appData={appData} value={bchWalletState.privateKey} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ padding: '25px' }}>
|
||||
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>Cash Address:</b> {bchWalletState.cashAddress}
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='cashAddress' appData={appData} value={bchWalletState.cashAddress} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
|
||||
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>SLP Address:</b> {bchWalletState.slpAddress}
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='slpAddress' appData={appData} value={bchWalletState.slpAddress} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ padding: '25px' }}>
|
||||
<Col xs={12} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>Legacy Address:</b> {bchWalletState.legacyAddress}
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='legacyAddress' appData={appData} value={bchWalletState.legacyAddress} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
|
||||
<Col xs={10} sm={10} lg={8} style={{ padding: '10px' }}>
|
||||
<b>HD Path:</b> {bchWalletState.hdPath}
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
<CopyOnClick walletProp='hdPath' appData={appData} value={bchWalletState.hdPath} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WalletSummary
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
This component is a visual warning against storing large sums of money in
|
||||
a web wallet.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col, Card } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const WebWalletWarning = () => {
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h2>
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} />{' '}
|
||||
<span>Web Wallets are Insecure</span>
|
||||
</h2>
|
||||
</Card.Title>
|
||||
|
||||
<Card.Text style={{ textAlign: 'center' }}>
|
||||
This is an open source, non-custodial web wallet
|
||||
supporting Bitcoin Cash (BCH) and SLP tokens.
|
||||
It is optimized for convenience and not security.
|
||||
<br />
|
||||
<b>Do not store large amounts of money on a web wallet.
|
||||
</b>
|
||||
<br /><br />
|
||||
Note: Scammers frequently copy this open source code to build
|
||||
apps for stealing people's money. Be sure you trust the source
|
||||
serving you this app.
|
||||
</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebWalletWarning
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Claim cosign — produce psffpp-share-claim-sig JSON using CashScript ECDSA.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Alert, Form } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { cosignClaim, listShareUtxos, DEFAULT_FEE_RATE } from '../../../services/psffpp'
|
||||
import { NeedConfig } from '../pool'
|
||||
|
||||
function ClaimCosignView (props) {
|
||||
const appData = props.appData
|
||||
const cfg = appData.deployConfig
|
||||
const [shareIndex, setShareIndex] = useState(0)
|
||||
const [utxoRef, setUtxoRef] = useState('')
|
||||
const [utxoOptions, setUtxoOptions] = useState([])
|
||||
const [feeRate, setFeeRate] = useState(String(DEFAULT_FEE_RATE))
|
||||
const [doc, setDoc] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cfg) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const info = await listShareUtxos(cfg, shareIndex)
|
||||
if (!cancelled) {
|
||||
setUtxoOptions(info.utxos)
|
||||
setUtxoRef(info.utxos.length === 1 ? info.utxos[0].ref : '')
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setUtxoOptions([])
|
||||
setError(err.message || String(err))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [cfg, shareIndex])
|
||||
|
||||
if (!cfg) return <NeedConfig />
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setDoc(null)
|
||||
try {
|
||||
if (!appData.wallet) throw new Error('Wallet not ready')
|
||||
const rate = Number(feeRate)
|
||||
const envelope = await cosignClaim({
|
||||
cfg,
|
||||
wallet: appData.wallet,
|
||||
shareIndex: Number(shareIndex),
|
||||
utxoRef: utxoRef || null,
|
||||
feeRate: rate
|
||||
})
|
||||
setDoc(envelope)
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
if (!doc) return
|
||||
const blob = new Blob([JSON.stringify(doc, null, 2) + '\n'], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `claim-sig-share${doc.claim.shareIndex}-by-node${doc.signer.nodeIndex}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
if (!doc) return
|
||||
await navigator.clipboard.writeText(JSON.stringify(doc, null, 2))
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Claim cosign</Card.Title>
|
||||
<Alert variant='warning'>
|
||||
This signs the <em>claim transaction</em> with CashScript ECDSA
|
||||
(<code>SIGHASH_ALL|UTXOS</code>). The Sign menu's Bitcoin message
|
||||
signatures cannot unlock ShareContract.claim.
|
||||
</Alert>
|
||||
<Card.Text>
|
||||
<strong>share index</strong> = which node's share UTXO is being claimed.
|
||||
Your wallet pubkey selects the signer slot (must match a config node pubkey).
|
||||
Give the JSON to the claimant; they need ≥2 distinct cosigners for the same claim params.
|
||||
</Card.Text>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Share being claimed</Form.Label>
|
||||
<Form.Select
|
||||
value={shareIndex}
|
||||
onChange={(e) => setShareIndex(Number(e.target.value))}
|
||||
>
|
||||
{cfg.nodes.map((n, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i} — {n.name || `node${i}`}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Share UTXO</Form.Label>
|
||||
{utxoOptions.length === 0
|
||||
? <Form.Text className='d-block text-muted'>No UTXOs on this share</Form.Text>
|
||||
: (
|
||||
<Form.Select value={utxoRef} onChange={(e) => setUtxoRef(e.target.value)}>
|
||||
<option value=''>— select —</option>
|
||||
{utxoOptions.map((u) => (
|
||||
<option key={u.ref} value={u.ref}>
|
||||
{u.ref} ({u.satoshis} sats)
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Fee rate (sats/byte)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
step='0.1'
|
||||
min='1'
|
||||
value={feeRate}
|
||||
onChange={(e) => setFeeRate(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className='d-flex flex-wrap gap-2 mb-3'>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={busy || !appData.wallet || !utxoRef}
|
||||
onClick={run}
|
||||
>
|
||||
{busy ? 'Signing…' : 'Generate claim signature'}
|
||||
</Button>
|
||||
<Button as={Link} to='/claim' variant='outline-secondary'>
|
||||
Go to claim broadcast
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{doc && (
|
||||
<>
|
||||
<Alert variant='success'>
|
||||
Signed as node {doc.signer.nodeIndex} ({doc.signer.name}) for share{' '}
|
||||
{doc.claim.shareIndex}. Payout {doc.claim.payoutSatoshis} sats
|
||||
(fee {doc.claim.feeSatoshis}).
|
||||
</Alert>
|
||||
<div className='d-flex gap-2 mb-2'>
|
||||
<Button size='sm' onClick={download}>Download JSON</Button>
|
||||
<Button size='sm' variant='outline-secondary' onClick={copy}>Copy JSON</Button>
|
||||
</div>
|
||||
<pre
|
||||
className='small p-2 border rounded'
|
||||
style={{ maxHeight: 320, overflow: 'auto' }}
|
||||
>
|
||||
{JSON.stringify(doc, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClaimCosignView
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Claim broadcast — assemble ≥2 claim-sig JSON files and broadcast.
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Alert, Form, Table } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { broadcastClaim, parseClaimSigDoc } from '../../../services/psffpp'
|
||||
import { NeedConfig } from '../pool'
|
||||
|
||||
function ClaimBroadcastView (props) {
|
||||
const appData = props.appData
|
||||
const cfg = appData.deployConfig
|
||||
const [sig1, setSig1] = useState('')
|
||||
const [sig2, setSig2] = useState('')
|
||||
const [sig3, setSig3] = useState('')
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (!cfg) return <NeedConfig />
|
||||
|
||||
const collectDocs = () => {
|
||||
const texts = [sig1, sig2, sig3].map((t) => t.trim()).filter(Boolean)
|
||||
if (texts.length < 2) {
|
||||
throw new Error('Paste at least two claim-sig JSON documents')
|
||||
}
|
||||
return texts.map((t) => parseClaimSigDoc(t))
|
||||
}
|
||||
|
||||
const loadFileInto = (setter) => (e) => {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setter(String(reader.result || ''))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const run = async (dryRun) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const docs = collectDocs()
|
||||
const summary = await broadcastClaim({
|
||||
cfg,
|
||||
wallet: appData.wallet,
|
||||
sigDocs: docs,
|
||||
dryRun
|
||||
})
|
||||
if (dryRun) setPreview(summary)
|
||||
else {
|
||||
setResult(summary)
|
||||
setPreview(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={9}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Claim broadcast</Card.Title>
|
||||
<Card.Text>
|
||||
Paste or upload ≥2 <code>psffpp-share-claim-sig</code> JSON files for the
|
||||
<em> same</em> claim (identical payout, UTXO, fee). Unused signature slots
|
||||
are empty bytes. Changing payout/fee after cosigning invalidates all signatures.
|
||||
</Card.Text>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
{result && (
|
||||
<Alert variant='success'>
|
||||
Claim broadcast OK. txid:{' '}
|
||||
<a href={`https://bch.loping.net/tx/${result.txid}`} target='_blank' rel='noreferrer'>
|
||||
{result.txid}
|
||||
</a>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{[
|
||||
{ label: 'Signature 1', value: sig1, set: setSig1 },
|
||||
{ label: 'Signature 2', value: sig2, set: setSig2 },
|
||||
{ label: 'Signature 3 (optional)', value: sig3, set: setSig3 }
|
||||
].map((slot) => (
|
||||
<Form.Group className='mb-3' key={slot.label}>
|
||||
<Form.Label>{slot.label}</Form.Label>
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
rows={6}
|
||||
value={slot.value}
|
||||
onChange={(e) => slot.set(e.target.value)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}
|
||||
/>
|
||||
<Form.Control
|
||||
type='file'
|
||||
accept='.json,application/json'
|
||||
className='mt-1'
|
||||
onChange={loadFileInto(slot.set)}
|
||||
/>
|
||||
</Form.Group>
|
||||
))}
|
||||
|
||||
<div className='d-flex flex-wrap gap-2 mb-3'>
|
||||
<Button variant='outline-primary' disabled={busy} onClick={() => run(true)}>
|
||||
Preview
|
||||
</Button>
|
||||
<Button variant='primary' disabled={busy || !appData.wallet} onClick={() => run(false)}>
|
||||
Broadcast claim
|
||||
</Button>
|
||||
<Button as={Link} to='/claim/cosign' variant='link'>
|
||||
Cosign instead
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<h6>Preview</h6>
|
||||
<Table size='sm' bordered>
|
||||
<tbody>
|
||||
<tr><th>Share</th><td>{preview.shareIndex} — {preview.shareAddress}</td></tr>
|
||||
<tr><th>Payout</th><td>{preview.payoutSatoshis} → {preview.payoutAddress}</td></tr>
|
||||
<tr><th>Fee</th><td>{preview.feeSatoshis} sats</td></tr>
|
||||
<tr><th>Signer slots</th><td>{preview.signerSlots.join(', ')}</td></tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<details>
|
||||
<summary>Hex</summary>
|
||||
<pre className='small' style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{preview.hex}</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClaimBroadcastView
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
This component is a View that allows the user to handle configuration
|
||||
settings for the app.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import ServerSelectView from './select-server-view'
|
||||
|
||||
function ConfigurationView (props) {
|
||||
const { appData } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerSelectView appData={appData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigurationView
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
This component contains a drop-down form that lets the user select from
|
||||
a range of Global Back End servers.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col, Button } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const ServerSelect = (props) => {
|
||||
const { linkTo, appData } = props
|
||||
|
||||
// Use the navigate function to navigate to the servers view
|
||||
const navigate = useNavigate()
|
||||
|
||||
// This is a click handler for the server select button. It brings up the
|
||||
// server selection View.
|
||||
const handleServerSelect = () => {
|
||||
console.log('This function should navigate to the server selection view.')
|
||||
navigate(linkTo)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<>
|
||||
<hr />
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center', padding: '25px' }}>
|
||||
<br />
|
||||
<h5>
|
||||
Having trouble loading? Try selecting a different back-end server.
|
||||
</h5>
|
||||
<p style={{ fontStyle: 'italic' }}>Current Server : {appData.serverUrl}</p>
|
||||
<Button variant='warning' onClick={handleServerSelect}>
|
||||
Select a different back end server
|
||||
</Button>
|
||||
<br />
|
||||
</Col>
|
||||
|
||||
</Row>
|
||||
</>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerSelect
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
This component is a View that allows the user to select a back end server
|
||||
from a list of servers.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Row, Col, Form, Card } from 'react-bootstrap'
|
||||
|
||||
function ServerSelectView (props) {
|
||||
const { appData } = props
|
||||
const [selectedServer, setSelectedServer] = useState(appData.serverUrl)
|
||||
const servers = appData.servers
|
||||
|
||||
// Update server when dropdown selection changes
|
||||
const handleServerChange = (event) => {
|
||||
setSelectedServer(event.target.value)
|
||||
}
|
||||
|
||||
const onSaveServer = (serverUrl) => {
|
||||
console.log('server target: ', serverUrl)
|
||||
appData.updateLocalStorage({ serverUrl })
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='m-3'>
|
||||
<Row className='mb-3 mt-3 mx-3'>
|
||||
<Col className='text-end'>
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
style={{ minWidth: '100px' }}
|
||||
onClick={() => onSaveServer(selectedServer)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<h2>Configuration</h2>
|
||||
<p>
|
||||
This page allows you to change configuration settings for different
|
||||
back end services. This page is for advanced users only.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<hr />
|
||||
|
||||
<Row className='justify-content-center'>
|
||||
<Col xs={12} md={6}>
|
||||
<p>
|
||||
Select an alternative server below. The app will reload and use
|
||||
the selected server.
|
||||
</p>
|
||||
<Form.Select
|
||||
value={selectedServer}
|
||||
onChange={handleServerChange}
|
||||
className='mb-3'
|
||||
>
|
||||
{servers.map((server, i) => (
|
||||
<option key={`server-${i}`} value={server.value}>
|
||||
{server.value === appData.serverUrl ? `${server.label} (current)` : server.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row className='justify-content-center mt-3'>
|
||||
<Col xs={12} md={6} className='text-center'>
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
style={{ minWidth: '100px' }}
|
||||
onClick={() => onSaveServer(selectedServer)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerSelectView
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Consolidate pool UTXOs (permissionless; fee from pool).
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Alert, Form, Table } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { consolidatePool, DEFAULT_FEE_RATE } from '../../../services/psffpp'
|
||||
import { NeedConfig } from '../pool'
|
||||
|
||||
function ConsolidateView (props) {
|
||||
const appData = props.appData
|
||||
const cfg = appData.deployConfig
|
||||
const [feeRate, setFeeRate] = useState(String(DEFAULT_FEE_RATE))
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (!cfg) return <NeedConfig />
|
||||
|
||||
const run = async (dryRun) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const rate = Number(feeRate)
|
||||
if (!Number.isFinite(rate) || rate <= 0) throw new Error('feeRate must be a positive number')
|
||||
const summary = await consolidatePool({
|
||||
cfg,
|
||||
wallet: appData.wallet,
|
||||
feeRate: rate,
|
||||
dryRun
|
||||
})
|
||||
if (dryRun) setPreview(summary)
|
||||
else {
|
||||
setResult(summary)
|
||||
setPreview(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Consolidate pool</Card.Title>
|
||||
<Card.Text>
|
||||
Merge multiple PoolContract UTXOs into one self-replicating output.
|
||||
Permissionless — no operator signatures. Miner fee is taken from the pool.
|
||||
</Card.Text>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
{result && (
|
||||
<Alert variant='success'>
|
||||
Broadcast OK. txid:{' '}
|
||||
<a href={`https://bch.loping.net/tx/${result.txid}`} target='_blank' rel='noreferrer'>
|
||||
{result.txid}
|
||||
</a>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Fee rate (sats/byte)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
step='0.1'
|
||||
min='1'
|
||||
value={feeRate}
|
||||
onChange={(e) => setFeeRate(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className='d-flex flex-wrap gap-2 mb-3'>
|
||||
<Button variant='outline-primary' disabled={busy} onClick={() => run(true)}>
|
||||
{busy ? 'Working…' : 'Preview (dry-run)'}
|
||||
</Button>
|
||||
<Button variant='primary' disabled={busy || !appData.wallet} onClick={() => run(false)}>
|
||||
Broadcast
|
||||
</Button>
|
||||
<Button as={Link} to='/pool' variant='link'>
|
||||
Back to pool
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<h6>Preview</h6>
|
||||
<Table size='sm' bordered>
|
||||
<tbody>
|
||||
<tr><th>Inputs</th><td>{preview.inputCount}</td></tr>
|
||||
<tr><th>In sum</th><td>{preview.inSum} sats</td></tr>
|
||||
<tr><th>Fee</th><td>{preview.fee} sats</td></tr>
|
||||
<tr><th>Out</th><td>{preview.outAmount} sats</td></tr>
|
||||
<tr><th>Min consolidation</th><td>{preview.minConsolidation}</td></tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<details>
|
||||
<summary>Hex</summary>
|
||||
<pre className='small' style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{preview.hex}</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConsolidateView
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Contracts config view — paste or upload deploy JSON (no private keys).
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Form, Button, Alert, Table } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
parseDeployConfigJson,
|
||||
createProvider,
|
||||
instantiateContracts,
|
||||
pkhToCashAddr
|
||||
} from '../../../services/psffpp'
|
||||
import exampleJson from '../../../contracts/deploy.example.json'
|
||||
|
||||
function ContractsConfig (props) {
|
||||
const appData = props.appData
|
||||
const [text, setText] = useState(
|
||||
appData.deployConfig ? JSON.stringify(appData.deployConfig, null, 2) : ''
|
||||
)
|
||||
const [error, setError] = useState(null)
|
||||
const [derived, setDerived] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const deriveAddresses = async (cfg) => {
|
||||
const provider = createProvider(cfg.network || 'mainnet')
|
||||
const { pool, shares } = instantiateContracts(cfg, provider)
|
||||
return {
|
||||
poolAddress: pool.address,
|
||||
shares: shares.map((s, i) => ({
|
||||
index: i,
|
||||
name: cfg.nodes[i].name || `node${i}`,
|
||||
address: s.address,
|
||||
payoutAddress: pkhToCashAddr(cfg.nodes[i].pkh, cfg.network || 'mainnet')
|
||||
})),
|
||||
treasuryAddress: pkhToCashAddr(cfg.treasuryPkh, cfg.network || 'mainnet'),
|
||||
splitBlockheight: cfg.splitBlockheight,
|
||||
minConsolidation: cfg.minConsolidation
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const cfg = parseDeployConfigJson(text)
|
||||
const addrs = await deriveAddresses(cfg)
|
||||
appData.setDeployConfig(cfg)
|
||||
setDerived(addrs)
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
setDerived(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
appData.setDeployConfig(null)
|
||||
setText('')
|
||||
setDerived(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleLoadExample = () => {
|
||||
setText(JSON.stringify(exampleJson, null, 2))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setText(String(reader.result || ''))
|
||||
setError(null)
|
||||
}
|
||||
reader.onerror = () => setError('Failed to read file')
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
// Re-derive on mount if config already saved
|
||||
React.useEffect(() => {
|
||||
if (!appData.deployConfig) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const addrs = await deriveAddresses(appData.deployConfig)
|
||||
if (!cancelled) setDerived(addrs)
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message || String(err))
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={10}>
|
||||
<Card className='mb-3'>
|
||||
<Card.Body>
|
||||
<Card.Title>Federation Deploy Config</Card.Title>
|
||||
<Card.Text>
|
||||
Paste or upload the same JSON used by the CLI
|
||||
(<code>config/deploy.mainnet.json</code>). No private keys.
|
||||
Config is stored in this browser's localStorage.
|
||||
</Card.Text>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Deploy JSON</Form.Label>
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
rows={16}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Or upload a file</Form.Label>
|
||||
<Form.Control type='file' accept='.json,application/json' onChange={handleFile} />
|
||||
</Form.Group>
|
||||
|
||||
<div className='d-flex flex-wrap gap-2'>
|
||||
<Button variant='primary' onClick={handleSave} disabled={busy || !text.trim()}>
|
||||
{busy ? 'Validating…' : 'Save & derive addresses'}
|
||||
</Button>
|
||||
<Button variant='outline-secondary' onClick={handleLoadExample}>
|
||||
Load example template
|
||||
</Button>
|
||||
<Button variant='outline-danger' onClick={handleClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{derived && (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Derived addresses</Card.Title>
|
||||
<p>
|
||||
Split height: <strong>{derived.splitBlockheight}</strong>
|
||||
{' · '}
|
||||
Min consolidation: <strong>{derived.minConsolidation}</strong> sats
|
||||
</p>
|
||||
<Table responsive size='sm' bordered>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Pool</th>
|
||||
<td style={{ wordBreak: 'break-all' }}>{derived.poolAddress}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Treasury</th>
|
||||
<td style={{ wordBreak: 'break-all' }}>{derived.treasuryAddress}</td>
|
||||
</tr>
|
||||
{derived.shares.map((s) => (
|
||||
<React.Fragment key={s.index}>
|
||||
<tr>
|
||||
<th>Share {s.index} ({s.name})</th>
|
||||
<td style={{ wordBreak: 'break-all' }}>{s.address}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Payout {s.index}</th>
|
||||
<td style={{ wordBreak: 'break-all' }}>{s.payoutAddress}</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Button as={Link} to='/pool' variant='success'>
|
||||
View pool status
|
||||
</Button>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContractsConfig
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
This Body component is a container for all the different Views of the app.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
// Local libraries
|
||||
import Wallet from './bch-wallet'
|
||||
import BchSend from './bch-send'
|
||||
import SignMessage from './sign/index.js'
|
||||
import ServerSelectView from './configuration/select-server-view'
|
||||
import ContractsConfig from './contracts'
|
||||
import PoolStatus from './pool'
|
||||
import ConsolidateView from './consolidate'
|
||||
import SplitView from './split'
|
||||
import ClaimCosignView from './claim-cosign'
|
||||
import ClaimBroadcastView from './claim'
|
||||
|
||||
function AppBody (props) {
|
||||
const appData = props.appData
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path='/' element={<BchSend appData={appData} />} />
|
||||
<Route path='/bch' element={<BchSend appData={appData} />} />
|
||||
<Route path='/wallet' element={<Wallet appData={appData} />} />
|
||||
<Route path='/sign' element={<SignMessage appData={appData} />} />
|
||||
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
|
||||
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
|
||||
<Route path='/contracts' element={<ContractsConfig appData={appData} />} />
|
||||
<Route path='/pool' element={<PoolStatus appData={appData} />} />
|
||||
<Route path='/consolidate' element={<ConsolidateView appData={appData} />} />
|
||||
<Route path='/split' element={<SplitView appData={appData} />} />
|
||||
<Route path='/claim/cosign' element={<ClaimCosignView appData={appData} />} />
|
||||
<Route path='/claim' element={<ClaimBroadcastView appData={appData} />} />
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppBody
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
This is a placeholder View
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
function Placeholder2 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 2 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Placeholder2
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
This is a placeholder View
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
function Placeholder3 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 3 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Placeholder3
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
Pool status — height gate, UTXO counts, CTAs.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Alert, Table, Spinner } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchPoolStatus } from '../../../services/psffpp'
|
||||
|
||||
function NeedConfig () {
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Alert variant='warning'>
|
||||
Load a federation deploy config first.{' '}
|
||||
<Alert.Link as={Link} to='/contracts'>Contracts config</Alert.Link>
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function PoolStatus (props) {
|
||||
const appData = props.appData
|
||||
const cfg = appData.deployConfig
|
||||
const [status, setStatus] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!cfg) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const s = await fetchPoolStatus(cfg)
|
||||
setStatus(s)
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [cfg])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
if (!cfg) return <NeedConfig />
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={10}>
|
||||
<Card className='mb-3'>
|
||||
<Card.Body>
|
||||
<div className='d-flex justify-content-between align-items-center mb-2'>
|
||||
<Card.Title className='mb-0'>Pool status</Card.Title>
|
||||
<Button size='sm' variant='outline-primary' onClick={refresh} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
{loading && !status && (
|
||||
<div className='text-center py-4'>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<>
|
||||
<p>
|
||||
Chain height: <strong>{status.height}</strong>
|
||||
{' / '}
|
||||
Split gate: <strong>{status.splitBlockheight}</strong>
|
||||
{' — '}
|
||||
{status.gateMet
|
||||
? <span className='text-success'>gate open</span>
|
||||
: <span className='text-warning'>gate closed</span>}
|
||||
</p>
|
||||
{status.hint && <Alert variant='info'>{status.hint}</Alert>}
|
||||
|
||||
<h6>Pool</h6>
|
||||
<p style={{ wordBreak: 'break-all' }} className='small mb-1'>{status.poolAddress}</p>
|
||||
<p>
|
||||
UTXOs: <strong>{status.poolUtxoCount}</strong>
|
||||
{' · '}
|
||||
Total: <strong>{status.poolTotalSatoshis}</strong> sats
|
||||
</p>
|
||||
{status.poolUtxos.length > 0 && (
|
||||
<Table size='sm' bordered responsive className='mb-3'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>txid:vout</th>
|
||||
<th>sats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.poolUtxos.map((u) => (
|
||||
<tr key={`${u.txid}:${u.vout}`}>
|
||||
<td style={{ wordBreak: 'break-all' }} className='small'>{u.txid}:{u.vout}</td>
|
||||
<td>{u.satoshis}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<h6>Shares</h6>
|
||||
<Table size='sm' bordered responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>UTXOs</th>
|
||||
<th>Total sats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.shares.map((s) => (
|
||||
<tr key={s.index}>
|
||||
<td>{s.index}</td>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.utxoCount}</td>
|
||||
<td>{s.totalSatoshis}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<div className='d-flex flex-wrap gap-2 mt-3'>
|
||||
{status.needsConsolidate && (
|
||||
<Button as={Link} to='/consolidate' variant='primary'>
|
||||
Consolidate
|
||||
</Button>
|
||||
)}
|
||||
{status.canSplit && (
|
||||
<Button as={Link} to='/split' variant='success'>
|
||||
Split
|
||||
</Button>
|
||||
)}
|
||||
<Button as={Link} to='/claim/cosign' variant='outline-secondary'>
|
||||
Claim cosign
|
||||
</Button>
|
||||
<Button as={Link} to='/claim' variant='outline-secondary'>
|
||||
Claim broadcast
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default PoolStatus
|
||||
export { NeedConfig }
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Component for signing a message with a WIF private key.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
|
||||
import { faCopy } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
|
||||
function SignMessage (props) {
|
||||
// Convert class state to hooks
|
||||
const { wallet, appUtil } = props.appData
|
||||
|
||||
const [sign, setSign] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [bchAddr] = useState(wallet?.walletInfo?.cashAddress)
|
||||
const [slpAddr] = useState(wallet?.walletInfo?.slpAddress)
|
||||
const [err, setErr] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleSignMessage = (event) => {
|
||||
try {
|
||||
event.preventDefault()
|
||||
|
||||
if (!msg) throw new Error('Enter a message to sign.')
|
||||
|
||||
const bchjs = wallet.bchjs
|
||||
|
||||
const wif = props.appData.wallet.walletInfo.privateKey
|
||||
const sig = bchjs.BitcoinCash.signMessageWithPrivKey(wif, msg)
|
||||
|
||||
setSign(sig)
|
||||
setErr('')
|
||||
} catch (err) {
|
||||
console.log('Error in handleSignMessage(): ', err)
|
||||
setErr(err.message)
|
||||
setSign('')
|
||||
}
|
||||
}
|
||||
|
||||
// Function to copy the value to the clipboard.
|
||||
const handleCopyToClipboard = async (value) => {
|
||||
appUtil.copyToClipboard(value)
|
||||
|
||||
// show the copied message
|
||||
setCopied(true)
|
||||
|
||||
// hide copied message after 1 second
|
||||
setTimeout(function () {
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const copyIcon = (value) => {
|
||||
return <FontAwesomeIcon icon={faCopy} size='lg' onClick={() => handleCopyToClipboard(value)} style={{ cursor: 'pointer', marginLeft: '10px' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<p>
|
||||
This view allows you cryptographically sign a message with your
|
||||
wallet. These signatures are used in a wide range of applications,
|
||||
such as gaining access to
|
||||
the <a href='https://t.me/psf_vip' target='_blank' rel='noreferrer'>PSF VIP Telegram channel</a>.
|
||||
</p>
|
||||
<p>
|
||||
Enter any message into the form below and click the button. This
|
||||
view will generate a cryptographic signature.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col className='text-break'>
|
||||
<Form onSubmit={handleSignMessage}>
|
||||
<Form.Group className='mb-3' controlId='message'>
|
||||
<Form.Label><b>Enter a message to sign.</b></Form.Label>
|
||||
<Form.Control type='text' placeholder='' onChange={e => setMsg(e.target.value)} />
|
||||
</Form.Group>
|
||||
{err && <p style={{ color: 'red', marginBottom: '10px' }}>{`Error: ${err}`}</p>}
|
||||
|
||||
<Button variant='primary' onClick={handleSignMessage}>
|
||||
Sign Message
|
||||
</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
{sign && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Col>
|
||||
<p>
|
||||
<b>Signature:</b> {sign} {copyIcon(sign)}
|
||||
</p>
|
||||
<p>
|
||||
<b>BCH Address:</b> {bchAddr} {copyIcon(bchAddr)}
|
||||
</p>
|
||||
<p>
|
||||
<b>SLP Address:</b> {slpAddr} {copyIcon(slpAddr)}
|
||||
</p>
|
||||
</Col>
|
||||
|
||||
</Row>
|
||||
{copied && (
|
||||
<span style={{ color: 'green' }}>
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignMessage
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
This is the 'Token View'. It displays the SLP tokens in the wallet.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Container, Row, Col, Spinner } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local libraries
|
||||
import TokenCard from './token-card'
|
||||
import RefreshTokenBalance from './refresh-tokens'
|
||||
|
||||
const SlpTokens = (props) => {
|
||||
const { appData } = props
|
||||
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
|
||||
const [dataAreLoaded, setDataAreLoaded] = useState(false)
|
||||
const [tokens, setTokens] = useState([])
|
||||
|
||||
const refreshTokenButtonRef = React.useRef()
|
||||
const { slpInitLoaded, asyncBackgroundFinished } = props.appData.asyncBackGroundInitState
|
||||
|
||||
// Background bch data loaded finished
|
||||
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
|
||||
const backgroundDataError = !slpInitLoaded && asyncBackgroundFinished
|
||||
|
||||
// Update the tokens state when the appData changes
|
||||
useEffect(() => {
|
||||
setTokens(appData.bchWalletState.slpTokens)
|
||||
}, [appData])
|
||||
|
||||
// This function is triggered when the token balance needs to be refreshed
|
||||
// from the blockchain.
|
||||
// This needs to happen after sending a token, to reflect the changed balance
|
||||
// within the wallet app.
|
||||
// This function triggers the on-click function within the refresh-tokens.js button.
|
||||
const refreshTokens = async () => {
|
||||
await refreshTokenButtonRef.current.handleRefreshTokenBalance()
|
||||
}
|
||||
|
||||
// Get Cid from url
|
||||
const parseCid = (url) => {
|
||||
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
|
||||
if (url && url.includes('ipfs://')) {
|
||||
const cid = url.split('ipfs://')[1]
|
||||
return cid
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// This function loads the token data .
|
||||
const lazyLoadTokenData = useCallback(async (tokens) => {
|
||||
try {
|
||||
setDataAreLoaded(false)
|
||||
// map each token and fetch the token data
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const thisToken = tokens[i]
|
||||
|
||||
// data does not need to be downloaded, so continue with the next one
|
||||
if (thisToken.dataAlreadyDownloaded) continue
|
||||
|
||||
// Try to get token data.
|
||||
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
|
||||
console.log('tokenData', tokenData)
|
||||
if (tokenData) {
|
||||
// Set data to the token object , this can be used to display the token name in the token card component.
|
||||
thisToken.tokenData = tokenData
|
||||
}
|
||||
|
||||
// Mark token to prevent fetch token data again.
|
||||
thisToken.dataAlreadyDownloaded = true
|
||||
}
|
||||
|
||||
setDataAreLoaded(true)
|
||||
} catch (error) {
|
||||
setDataAreLoaded(true)
|
||||
}
|
||||
}, [appData])
|
||||
|
||||
// Fetch mutable data if it exist and get the token icon url
|
||||
const fetchTokenMutableData = useCallback(async (token) => {
|
||||
try {
|
||||
// Get the token data
|
||||
const tokenData = token.tokenData
|
||||
|
||||
if (!tokenData.mutableData) return false // Return false if no mutable data
|
||||
|
||||
// Get the token icon from the mutable data
|
||||
const cid = parseCid(tokenData.mutableData)
|
||||
console.log('mutable data cid', cid)
|
||||
|
||||
const { json } = await appData.wallet.cid2json({ cid })
|
||||
console.log('json: ', json)
|
||||
if (!json) return false
|
||||
|
||||
let iconUrl = json.tokenIcon
|
||||
|
||||
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
|
||||
iconUrl = json.fullSizedUrl
|
||||
}
|
||||
const userData = json.userData
|
||||
// Return icon url
|
||||
return { iconUrl, userData }
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}, [appData])
|
||||
|
||||
// This function loads the token icons from the ipfs gateways.
|
||||
const lazyLoadMutableData = useCallback(async (tokens) => {
|
||||
try {
|
||||
setIconsAreLoaded(false)
|
||||
// map each token and fetch the icon url
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const thisToken = tokens[i]
|
||||
|
||||
// Incon does not need to be downloaded, so continue with the next one
|
||||
if (thisToken.iconAlreadyDownloaded) continue
|
||||
|
||||
// Try to get token icon url from mutable data.
|
||||
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
|
||||
console.log('iconUrl', iconUrl)
|
||||
if (iconUrl) {
|
||||
// Set the icon url to the token , this can be used to display the icon in the token card component.
|
||||
thisToken.icon = iconUrl
|
||||
thisToken.tokenData.userData = userData
|
||||
}
|
||||
|
||||
// Mark token to prevent fetch token icon again.
|
||||
thisToken.iconAlreadyDownloaded = true
|
||||
}
|
||||
|
||||
setIconsAreLoaded(true)
|
||||
} catch (error) {
|
||||
setIconsAreLoaded(true)
|
||||
}
|
||||
}, [fetchTokenMutableData])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const tokens = appData.bchWalletState.slpTokens
|
||||
console.log('tokens', tokens)
|
||||
setTokens(tokens)
|
||||
await lazyLoadTokenData(tokens)
|
||||
await lazyLoadMutableData(tokens)
|
||||
}, [appData, lazyLoadTokenData, lazyLoadMutableData])
|
||||
|
||||
// Start to load the token icons when the component is mounted
|
||||
useEffect(() => {
|
||||
if (slpInitLoaded) {
|
||||
loadData()
|
||||
}
|
||||
}, [loadData, slpInitLoaded])
|
||||
|
||||
// Generate the token cards for each token in the wallet.
|
||||
const generateCards = () => {
|
||||
const tokens = appData.bchWalletState.slpTokens
|
||||
return tokens.map(thisToken => (
|
||||
<TokenCard
|
||||
appData={appData}
|
||||
token={thisToken}
|
||||
key={`${thisToken.tokenId}`}
|
||||
refreshTokens={refreshTokens}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<RefreshTokenBalance
|
||||
appData={appData}
|
||||
ref={refreshTokenButtonRef}
|
||||
lazyLoadTokenIcons={loadData}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} style={{ textAlign: 'right' }}>
|
||||
<a href='https://youtu.be/f1no5-QHTr4' target='_blank' rel='noreferrer'>
|
||||
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
{appData.asyncInitSucceeded && (
|
||||
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{/** Show spinner info if tokens are loaded but data is not loaded */
|
||||
!backgroundDataLoaded && !backgroundDataError && (
|
||||
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
|
||||
<span style={{ marginRight: '10px' }}>Loading Tokens </span>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/** Show spinner info if tokens are loaded but data is not loaded */
|
||||
!backgroundDataError && !dataAreLoaded && tokens.length > 0 && (
|
||||
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
|
||||
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/** Show spinner info if tokens are loaded but icons are not loaded */
|
||||
backgroundDataLoaded && dataAreLoaded && !iconsAreLoaded && (
|
||||
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
|
||||
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
|
||||
<Spinner animation='border' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
{generateCards()}
|
||||
</Row>
|
||||
{/** Display a message if no tokens are found */}
|
||||
{backgroundDataLoaded && !backgroundDataError && tokens.length === 0 && (
|
||||
<Row className='text-center'>
|
||||
<span> No tokens found in wallet </span>
|
||||
</Row>
|
||||
)}
|
||||
{backgroundDataError && (
|
||||
<Row style={{ color: 'red' }} className='text-center'>
|
||||
<span>Tokens could not be loaded! </span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlpTokens
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
This component renders as a button. When clicked, it opens a modal that
|
||||
displays information about the token.
|
||||
|
||||
This is a functional component with as little state as possible.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
|
||||
|
||||
// Takes a string as input. If it matches a pattern for a link, a JSX object is
|
||||
// returned with a link. Otherwise the original string is returned.
|
||||
function linkIfUrl (url) {
|
||||
// Convert the URL into a link if it contains 'http'
|
||||
if (url.includes('http')) {
|
||||
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
|
||||
|
||||
//
|
||||
} else if (url.includes('ipfs://')) {
|
||||
// Convert to a Filecoin link if its an IPFS reference.
|
||||
|
||||
const cid = url.substring(7)
|
||||
url = (<a href={`https://${cid}.ipfs.dweb.link/data.json`} target='_blank' rel='noreferrer'>{url}</a>)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function InfoButton (props) {
|
||||
const [show, setShow] = useState(false)
|
||||
const [mutableDataCid, setMutableDataCid] = useState(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setShow(false)
|
||||
// props.instance.setState({ showModal: false })
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
setShow(true)
|
||||
}
|
||||
|
||||
// Convert the url property of the token to a link, if it matches common patterns.
|
||||
let url = props.token.url
|
||||
url = linkIfUrl(props.token.url)
|
||||
|
||||
// console.log('props.token: ', props.token)
|
||||
|
||||
// Get Cid from url
|
||||
const parseCid = (url) => {
|
||||
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
|
||||
if (url && url.includes('ipfs://')) {
|
||||
const cid = url.split('ipfs://')[1]
|
||||
return cid
|
||||
}
|
||||
return url
|
||||
}
|
||||
// Get token user data if it exists and verify if it contains media or markdown
|
||||
useEffect(() => {
|
||||
try {
|
||||
console.log('props token', props.token)
|
||||
const userDataStr = props.token.tokenData.userData
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr)
|
||||
|
||||
// If user data contains media or markdown, set the mutable data cid
|
||||
if (userData?.media || userData?.markdown) {
|
||||
setMutableDataCid(parseCid(props.token.tokenData.mutableData))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Do nothing
|
||||
}
|
||||
}, [props.token, show])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant='info' onClick={handleOpen}>Info</Button>
|
||||
<Modal show={show} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Token Information</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col xs={4}><b>Ticker</b>:</Col>
|
||||
<Col xs={8}>{props.token.ticker}</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ backgroundColor: '#eee' }}>
|
||||
<Col xs={4}><b>Name</b>:</Col>
|
||||
<Col xs={8}>{props.token.name}</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={4}><b>Token ID</b>:</Col>
|
||||
<Col xs={8} style={{ wordBreak: 'break-all' }}>
|
||||
<a href={`https://token.fullstack.cash/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
|
||||
{props.token.tokenId}
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ backgroundColor: '#eee' }}>
|
||||
<Col xs={4}><b>Decimals</b>:</Col>
|
||||
<Col xs={8}>{props.token.decimals}</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={4}><b>Token Type</b>:</Col>
|
||||
<Col xs={8}>{props.token.tokenType}</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
|
||||
<Col xs={4}><b>URL</b>:</Col>
|
||||
<Col xs={8}>{url}</Col>
|
||||
</Row>
|
||||
{!props.token.iconAlreadyDownloaded && (
|
||||
<div className='text-center'>
|
||||
<Spinner animation='border' size='sm' />
|
||||
</div>
|
||||
)}
|
||||
{props.token.iconAlreadyDownloaded && mutableDataCid && (
|
||||
<Row style={{ paddingTop: '10px' }}>
|
||||
<Col xs={4}><b>User Data</b>:</Col>
|
||||
<Col xs={8}>
|
||||
<Button
|
||||
href={`/user-data/${props.token.tokenId}#single-view`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
View User Data
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
<Modal.Footer />
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoButton
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
This component is displayed as a button. When clicked, it displays a modal
|
||||
with a spinny gif, while the wallets SLP token list is updated from the
|
||||
blockchain and psf-slp-indexer.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faRedo } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
// Local libraries
|
||||
import WaitingModal from '../../waiting-modal'
|
||||
|
||||
function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons }) {
|
||||
const [appData, setAppData] = useState(initialAppData)
|
||||
const [modalBody, setModalBody] = useState([])
|
||||
const [hideSpinner, setHideSpinner] = useState(false)
|
||||
const [hideWaitingModal, setHideWaitingModal] = useState(true)
|
||||
const { slpInitLoaded, asyncBackgroundFinished } = initialAppData.asyncBackGroundInitState
|
||||
|
||||
// Background bch data loaded finished
|
||||
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
|
||||
|
||||
// Add a new line to the waiting modal.
|
||||
const addToModal = (inStr) => {
|
||||
setModalBody(prevBody => [...prevBody, inStr])
|
||||
}
|
||||
|
||||
// Update the balance of the wallet.
|
||||
const handleRefreshTokenBalance = useCallback(async () => {
|
||||
try {
|
||||
// Throw up the waiting modal
|
||||
setHideWaitingModal(false)
|
||||
addToModal('Updating token balance...')
|
||||
|
||||
// Get handles on app data.
|
||||
const walletState = appData.bchWalletState
|
||||
const wallet = appData.wallet
|
||||
|
||||
// Update the wallet UTXOs
|
||||
await wallet.initialize()
|
||||
const tokenList = await wallet.listTokens()
|
||||
|
||||
// Copy tokens from old token state.
|
||||
for (let i = 0; i < tokenList.length; i++) {
|
||||
const thisToken = tokenList[i]
|
||||
|
||||
// Look through the existing wallet state for the matching token.
|
||||
const existingToken = walletState.slpTokens.filter(x => x.tokenId === thisToken.tokenId)
|
||||
|
||||
// If the current wallet state has an icon, copy it over.
|
||||
if (existingToken[0] && existingToken[0].icon) {
|
||||
thisToken.icon = existingToken[0].icon
|
||||
}
|
||||
}
|
||||
|
||||
// Update the wallet state.
|
||||
walletState.slpTokens = tokenList
|
||||
|
||||
appData.updateBchWalletState({ walletObj: walletState, appData })
|
||||
|
||||
const newAppData = { ...appData, bchWalletState: walletState }
|
||||
|
||||
// if slpInitLoaded is 'false', them set as true , to show the new balance.
|
||||
appData.updateBackGroundInitState({ slpInitLoaded: true })
|
||||
|
||||
// Update state
|
||||
setHideWaitingModal(true)
|
||||
setAppData(newAppData)
|
||||
setModalBody([])
|
||||
|
||||
// Lazy load icons for any new tokens.
|
||||
await lazyLoadTokenIcons()
|
||||
|
||||
return newAppData
|
||||
} catch (err) {
|
||||
console.error('Error while trying to update BCH balance: ', err)
|
||||
setModalBody([`Error: ${err.message}`])
|
||||
setHideSpinner(true)
|
||||
}
|
||||
}, [appData, lazyLoadTokenIcons])
|
||||
|
||||
// add a ref to the handleRefreshBalance function
|
||||
// This is used to call the function from the parent component.
|
||||
useEffect(() => {
|
||||
if (ref && !ref.current) ref.current = { handleRefreshTokenBalance }
|
||||
}, [ref, handleRefreshTokenBalance])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant='success' onClick={handleRefreshTokenBalance} disabled={!backgroundDataLoaded}>
|
||||
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
|
||||
</Button>
|
||||
|
||||
{!hideWaitingModal && (
|
||||
<WaitingModal
|
||||
heading='Refreshing Token List'
|
||||
body={modalBody}
|
||||
hideSpinner={hideSpinner}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshTokenBalance
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
This component renders as a button. When clicked, it opens up a modal
|
||||
for sending a quantity of tokens.
|
||||
This component requires state, because it's a complex form that is being manipulated
|
||||
by the user.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Button, Modal, Container, Row, Col, Form, Spinner } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPaperPlane, faPaste } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
function SendTokenButton ({ token, appData, refreshTokens }) {
|
||||
// Convert class state to useState hooks
|
||||
const [showAddrWarning, setShowAddrWarning] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
const [hideSpinner, setHideSpinner] = useState(true)
|
||||
const [shouldRefreshOnModalClose, setShouldRefreshOnModalClose] = useState(false)
|
||||
const [sendToAddress, setSendToAddress] = useState('')
|
||||
const [sendQtyStr, setSendQtyStr] = useState('')
|
||||
const [dialogFinished, setDialogFinished] = useState(true)
|
||||
|
||||
// Handler functions
|
||||
const handleShowModal = () => setShowModal(true)
|
||||
|
||||
const handleCloseModal = async () => {
|
||||
if (!dialogFinished) return
|
||||
|
||||
if (shouldRefreshOnModalClose) {
|
||||
setShowModal(false)
|
||||
setShouldRefreshOnModalClose(false)
|
||||
setStatusMsg('')
|
||||
await refreshTokens()
|
||||
} else {
|
||||
setShowModal(false)
|
||||
setStatusMsg('')
|
||||
setSendToAddress('')
|
||||
setSendQtyStr('')
|
||||
}
|
||||
}
|
||||
const handleUpdateSendToAddr = (event) => {
|
||||
const value = event.target.value
|
||||
setSendToAddress(value)
|
||||
setShowAddrWarning(value.includes('bitcoincash'))
|
||||
}
|
||||
const handleGetMax = () => {
|
||||
setSendQtyStr(token.qty)
|
||||
}
|
||||
|
||||
// Click handler that fires when the user clicks the 'Send' button.
|
||||
|
||||
const handleSendTokens = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
setStatusMsg('Preparing to send tokens...')
|
||||
setHideSpinner(false)
|
||||
setDialogFinished(false)
|
||||
setShowAddrWarning(false)
|
||||
|
||||
// Validate the quantity
|
||||
const qty = parseFloat(sendQtyStr)
|
||||
if (isNaN(qty)) throw new Error('Invalid send quantity')
|
||||
|
||||
const wallet = appData.wallet
|
||||
const bchjs = wallet.bchjs
|
||||
|
||||
// Validate the address
|
||||
let addr = sendToAddress
|
||||
if (addr.includes('simpleledger')) {
|
||||
addr = bchjs.SLP.Address.toCashAddress(addr)
|
||||
}
|
||||
if (!addr.includes('bitcoincash')) throw new Error('Invalid address')
|
||||
|
||||
let infoStr = 'Updating UTXOs...'
|
||||
|
||||
setStatusMsg(infoStr)
|
||||
await wallet.getUtxos()
|
||||
|
||||
const receiver = [{
|
||||
address: addr,
|
||||
tokenId: token.tokenId,
|
||||
qty
|
||||
}]
|
||||
|
||||
infoStr = 'Generating and broadcasting transaction...'
|
||||
setStatusMsg(infoStr)
|
||||
|
||||
const txid = await wallet.sendTokens(receiver, 3)
|
||||
console.log(`Token sent. TXID: ${txid}`)
|
||||
|
||||
setStatusMsg(<p>Success! <a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>See on Block Explorer</a></p>)
|
||||
setHideSpinner(true)
|
||||
setSendQtyStr('')
|
||||
setSendToAddress('')
|
||||
setShouldRefreshOnModalClose(true)
|
||||
setDialogFinished(true)
|
||||
} catch (err) {
|
||||
console.error('Error in handleSendTokens(): ', err)
|
||||
setStatusMsg(`Error sending tokens: ${err.message}`)
|
||||
setHideSpinner(true)
|
||||
setDialogFinished(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Load address from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const address = await appData.appUtil.readFromClipboard()
|
||||
setSendToAddress(address)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Modal JSX
|
||||
const getModal = () => {
|
||||
return (
|
||||
<Modal show={showModal} size='lg' onHide={handleCloseModal}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send Tokens: <span style={{ color: 'red' }}>{token.ticker}</span></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Container>
|
||||
{/* ... existing Modal.Body content ... */}
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<b>SLP Address:</b>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Form onSubmit={(e) => e.preventDefault()}>
|
||||
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
|
||||
<Form.Control
|
||||
type='text'
|
||||
placeholder='simpleledger:qqlrzp23w08434twmvr4fxw672whkjy0pyxpgpyg0n'
|
||||
onChange={handleUpdateSendToAddr}
|
||||
value={sendToAddress}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
<FontAwesomeIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={faPaste}
|
||||
size='lg'
|
||||
onClick={pasteFromClipboard}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<b>Amount:</b>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Form style={{ paddingBottom: '10px' }} onSubmit={handleSendTokens}>
|
||||
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
|
||||
<Form.Control
|
||||
type='text'
|
||||
onChange={e => setSendQtyStr(e.target.value)}
|
||||
value={sendQtyStr}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
<Button onClick={handleGetMax}>Max</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<Button onClick={handleSendTokens}>Send</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
{showAddrWarning && (
|
||||
<>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<p style={{ color: 'orange' }}>
|
||||
<b>Warning</b>: Careful! Not all Bitcoin Cash wallets are token-aware.
|
||||
If you send this token to a wallet that is not
|
||||
token-aware, it could be burned. It's best practice to
|
||||
only send tokens to 'simpleledger' addresses and not
|
||||
'bitcoincash' addresses.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
{statusMsg}
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
{!hideSpinner && <Spinner animation='border' />}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
<Modal.Footer />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant='info' onClick={handleShowModal}>Send</Button>
|
||||
{showModal && getModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SendTokenButton
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
This Card component summarizes an SLP token.
|
||||
if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Card } from 'react-bootstrap'
|
||||
import Jdenticon from '@chris.troutner/react-jdenticon'
|
||||
// Local libraries
|
||||
import InfoButton from './info-button'
|
||||
import SendTokenButton from './send-token-button'
|
||||
|
||||
function TokenCard (props) {
|
||||
const { token } = props
|
||||
const [icon, setIcon] = useState(token.icon)
|
||||
|
||||
// Update icon state every token.icon changes
|
||||
useEffect(() => {
|
||||
setIcon(token.icon)
|
||||
}, [token.icon])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
|
||||
<Card>
|
||||
<Card.Body style={{ textAlign: 'center' }}>
|
||||
{/** If the icon is loaded, display it */
|
||||
icon && (
|
||||
<Card.Img
|
||||
src={icon}
|
||||
style={{ height: '100px', width: 'auto' }}
|
||||
onError={(e) => {
|
||||
setIcon(null) // Set the icon to null if it fails to load the image url.
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/** If the icon is not loaded, display the Jdenticon */
|
||||
!icon && (
|
||||
<Jdenticon size='100' value={token.tokenId} />
|
||||
)
|
||||
}
|
||||
<Card.Title style={{ textAlign: 'center' }}>
|
||||
<h4>{props.token.ticker}</h4>
|
||||
</Card.Title>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
{props.token.name}
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col>Balance:</Col>
|
||||
<Col>{props.token.qty}</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<InfoButton token={props.token} />
|
||||
</Col>
|
||||
<Col>
|
||||
<SendTokenButton
|
||||
token={props.token}
|
||||
appData={props.appData}
|
||||
refreshTokens={props.refreshTokens}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenCard
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Split pool after height gate. Fee from in-app wallet (auto-carve).
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Card, Button, Alert, Form, Table } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { splitPool, DEFAULT_FEE_RATE } from '../../../services/psffpp'
|
||||
import { NeedConfig } from '../pool'
|
||||
|
||||
function SplitView (props) {
|
||||
const appData = props.appData
|
||||
const cfg = appData.deployConfig
|
||||
const [feeRate, setFeeRate] = useState(String(DEFAULT_FEE_RATE))
|
||||
const [statusMsg, setStatusMsg] = useState(null)
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (!cfg) return <NeedConfig />
|
||||
|
||||
const run = async (dryRun) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setStatusMsg(null)
|
||||
try {
|
||||
if (!appData.wallet) throw new Error('Wallet not ready')
|
||||
const rate = Number(feeRate)
|
||||
if (!Number.isFinite(rate) || rate <= 0) throw new Error('feeRate must be a positive number')
|
||||
const summary = await splitPool({
|
||||
cfg,
|
||||
wallet: appData.wallet,
|
||||
feeRate: rate,
|
||||
dryRun,
|
||||
onStatus: setStatusMsg
|
||||
})
|
||||
if (dryRun) setPreview(summary)
|
||||
else {
|
||||
setResult(summary)
|
||||
setPreview(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setStatusMsg(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='mt-3'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Split pool</Card.Title>
|
||||
<Card.Text>
|
||||
After <code>splitBlockheight</code>, apportions one pool UTXO into treasury (20%)
|
||||
+ three equal ShareContracts (80%/3). Miner fee comes from this wallet as a
|
||||
companion input. Large UTXOs are never spent as the fee — a small self-send is
|
||||
carved when needed.
|
||||
</Card.Text>
|
||||
|
||||
{error && <Alert variant='danger'>{error}</Alert>}
|
||||
{statusMsg && <Alert variant='info'>{statusMsg}</Alert>}
|
||||
{result && (
|
||||
<Alert variant='success'>
|
||||
Broadcast OK. txid:{' '}
|
||||
<a href={`https://bch.loping.net/tx/${result.txid}`} target='_blank' rel='noreferrer'>
|
||||
{result.txid}
|
||||
</a>
|
||||
{result.carveTxid && (
|
||||
<div className='mt-1 small'>Carve tx: {result.carveTxid}</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className='mb-3'>
|
||||
<Form.Label>Fee rate (sats/byte)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
step='0.1'
|
||||
min='1'
|
||||
value={feeRate}
|
||||
onChange={(e) => setFeeRate(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<p className='small text-muted'>
|
||||
Fee payer: {appData.bchWalletState?.cashAddress || '…'}
|
||||
</p>
|
||||
|
||||
<div className='d-flex flex-wrap gap-2 mb-3'>
|
||||
<Button variant='outline-primary' disabled={busy} onClick={() => run(true)}>
|
||||
{busy ? 'Working…' : 'Preview (dry-run)'}
|
||||
</Button>
|
||||
<Button variant='success' disabled={busy || !appData.wallet} onClick={() => run(false)}>
|
||||
Broadcast split
|
||||
</Button>
|
||||
<Button as={Link} to='/pool' variant='link'>
|
||||
Back to pool
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<h6>Preview</h6>
|
||||
<Table size='sm' bordered>
|
||||
<tbody>
|
||||
<tr><th>Height</th><td>{preview.chainHeight} / gate {preview.splitBlockheight}</td></tr>
|
||||
<tr><th>Pool value</th><td>{preview.poolValue} sats</td></tr>
|
||||
<tr><th>Treasury</th><td>{preview.treasuryValue} → {preview.treasuryAddr}</td></tr>
|
||||
<tr><th>Each share</th><td>{preview.share} sats</td></tr>
|
||||
<tr><th>Fee UTXO</th><td>{preview.feeUtxo.satoshis} sats (carved: {String(preview.carved)})</td></tr>
|
||||
<tr><th>Est. fee</th><td>{preview.estimatedFeeAtRate} sats</td></tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
{preview.hex && (
|
||||
<details>
|
||||
<summary>Hex</summary>
|
||||
<pre className='small' style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{preview.hex}</pre>
|
||||
</details>
|
||||
)}
|
||||
{preview.carved === 'would_carve' && (
|
||||
<Alert variant='warning' className='mt-2'>
|
||||
Dry-run would carve a {preview.carveAmount}-sat fee UTXO before split.
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitView
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
This Sweep component allows users to sweep a private key and transfer any
|
||||
BCH or SLP tokens into their wallet.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React from 'react'
|
||||
import { Container, Row, Col, Form, Button, Modal, Spinner } from 'react-bootstrap'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
||||
import Sweeper from 'bch-token-sweep'
|
||||
|
||||
// let _this
|
||||
|
||||
const SweepWif = (props) => {
|
||||
const { appData } = props
|
||||
console.log('appData', appData)
|
||||
const [wifToSweep, setWifToSweep] = React.useState('')
|
||||
const [showModal, setShowModal] = React.useState(false)
|
||||
const [statusMsg, setStatusMsg] = React.useState('')
|
||||
const [hideSpinner, setHideSpinner] = React.useState(false)
|
||||
|
||||
// shouldRefreshOnModalClose: false
|
||||
|
||||
// Helper function to validate WIF
|
||||
const validateWIF = (WIF) => {
|
||||
if (typeof WIF !== 'string') return false
|
||||
if (WIF.length !== 52) return false
|
||||
if (WIF[0] !== 'L' && WIF[0] !== 'K') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Update wallet state function
|
||||
const updateWalletState = async () => {
|
||||
const wallet = appData.wallet
|
||||
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
|
||||
await wallet.initialize()
|
||||
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
|
||||
appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData })
|
||||
}
|
||||
|
||||
// Handle sweep function
|
||||
const handleSweep = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
console.log(`Sweeping this WIF: ${wifToSweep}`)
|
||||
|
||||
// Set modal initial state
|
||||
setShowModal(true)
|
||||
setHideSpinner(false)
|
||||
setStatusMsg('')
|
||||
|
||||
// Input validation
|
||||
const isWIF = validateWIF(wifToSweep)
|
||||
if (!isWIF) {
|
||||
setHideSpinner(true)
|
||||
setStatusMsg(<b style={{ color: 'red' }}>Input is not a WIF private key.</b>)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const walletWif = appData.wallet.walletInfo.privateKey
|
||||
const toAddr = appData.wallet.slpAddress
|
||||
|
||||
// Instance the Sweep library
|
||||
const sweep = new Sweeper(wifToSweep, walletWif, appData.wallet)
|
||||
await sweep.populateObjectFromNetwork()
|
||||
|
||||
// Constructing the sweep transaction
|
||||
const hex = await sweep.sweepTo(toAddr)
|
||||
const txid = await appData.wallet.ar.sendTx(hex)
|
||||
|
||||
// Generate status message
|
||||
const newStatusMsg = (
|
||||
<>
|
||||
<p>Sweep succeeded!</p>
|
||||
<p>Transaction ID: {txid}</p>
|
||||
<p>
|
||||
<a href={`https://blockchair.com/bitcoin-cash/transaction/${txid}`} target='_blank' rel='noreferrer'>
|
||||
TX on Blockchair BCH Block Explorer
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>
|
||||
TX on token explorer
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
||||
setHideSpinner(true)
|
||||
setStatusMsg(newStatusMsg)
|
||||
setWifToSweep('')
|
||||
|
||||
await updateWalletState()
|
||||
} catch (err) {
|
||||
setHideSpinner(true)
|
||||
setStatusMsg(<b style={{ color: 'red' }}>{`Error: ${err.message}`}</b>)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in handleSweep(): ', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Modal component
|
||||
const getModal = () => (
|
||||
<Modal show={showModal} size='lg' onHide={() => setShowModal(false)}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Sweeping...</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Container>
|
||||
<Row>
|
||||
{!hideSpinner && (
|
||||
<Col style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: '10px' }}>Sweeping private key...</span> <Spinner animation='border' />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<br />
|
||||
{statusMsg && (
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>{statusMsg}</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
<Modal.Footer />
|
||||
</Modal>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'right' }}>
|
||||
<a href='https://youtu.be/QW9xixHaEJE' target='_blank' rel='noreferrer'>
|
||||
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<p>
|
||||
This View is used to 'sweep' a private key. This will transfer
|
||||
any BCH or SLP tokens from a paper wallet to your web wallet.
|
||||
Paper wallets are used to store BCH and tokens. You
|
||||
can <a href='https://paperwallet.fullstack.cash/' target='_blank' rel='noreferrer'>generate paper wallets here</a>.
|
||||
</p>
|
||||
<p>
|
||||
Paste the private key of a paper wallet below and click the button
|
||||
to sweep the funds. The private key must be in WIF format. It will
|
||||
start with the letter 'K' or 'L'.
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<Form onSubmit={handleSweep}>
|
||||
<Form.Group controlId='formWif' style={{ textAlign: 'center' }}>
|
||||
<Form.Control
|
||||
type='text'
|
||||
placeholder='KzJqZxi5XSo36woCy7MFVNRPDpfp8x8FpkhRvrErKBBrDXRVY9Ft'
|
||||
onChange={(e) => setWifToSweep(e.target.value)}
|
||||
value={wifToSweep}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Row style={{ textAlign: 'center' }}>
|
||||
<Col>
|
||||
<Button variant='info' onClick={handleSweep}>
|
||||
Sweep
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
{showModal && getModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SweepWif
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Search in the provided cid of a mutable data, get the data from the user data and show it
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import '../../App.css'
|
||||
|
||||
function UserDataReview (props) {
|
||||
const appData = props.appData
|
||||
const [media, setMedia] = useState([])
|
||||
const [markdown, setMarkdown] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [tokenData, setTokenData] = useState(null)
|
||||
// Get the id parameter from the URL
|
||||
const { tokenId } = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const tokenData = await appData.wallet.getTokenData(tokenId)
|
||||
|
||||
setTokenData(tokenData)
|
||||
setLoading(true)
|
||||
|
||||
if (!tokenData.mutableData) throw new Error('Mutable data not found')
|
||||
|
||||
const cid = parseCid(tokenData.mutableData)
|
||||
|
||||
const { json } = await appData.wallet.cid2json({ cid })
|
||||
const userDataString = json.userData
|
||||
|
||||
if (userDataString) {
|
||||
const userData = JSON.parse(userDataString)
|
||||
setMedia(userData.media)
|
||||
setMarkdown(userData.markdown)
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [tokenId, appData.wallet])
|
||||
|
||||
// Get Cid from url
|
||||
const parseCid = (url) => {
|
||||
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
|
||||
if (url && url.includes('ipfs://')) {
|
||||
const cid = url.split('ipfs://')[1]
|
||||
return cid
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
{/** Error message */}
|
||||
{error && <p className='text-danger'>{error}</p>}
|
||||
|
||||
{/** Loading spinner */}
|
||||
{loading &&
|
||||
(
|
||||
<div className='text-center my-5'>
|
||||
<Spinner animation='border' role='status' variant='primary'>
|
||||
<span className='visually-hidden'>Loading...</span>
|
||||
</Spinner>
|
||||
</div>
|
||||
)}
|
||||
{/** User data */}
|
||||
{!loading && !error && (
|
||||
<div className='text-center'>
|
||||
{/** Name */}
|
||||
<h1>{tokenData.genesisData.name}</h1>
|
||||
|
||||
{/** Media Content Carousel */}
|
||||
{media && media.length > 0
|
||||
? (
|
||||
<div className='my-5 '>
|
||||
<Carousel>
|
||||
{media.map((item, index) => (
|
||||
<Carousel.Item key={index}>
|
||||
<img
|
||||
className='d-block w-100'
|
||||
src={item.url}
|
||||
alt={`Review media ${index + 1}`}
|
||||
style={{ height: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
<Carousel.Caption>
|
||||
<p>Image {index + 1} of {media.length}</p>
|
||||
</Carousel.Caption>
|
||||
</Carousel.Item>
|
||||
))}
|
||||
</Carousel>
|
||||
<style>{'.carousel-control-next,.carousel-control-prev, .carousel-indicators {filter: invert(100%); } '}
|
||||
</style>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p className='mt-3'>No media content available</p>
|
||||
)}
|
||||
|
||||
{/** Markdown Content */}
|
||||
{markdown
|
||||
? (
|
||||
<div className='markdown-content my-5'>
|
||||
<ReactMarkdown>{markdown}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p className='mt-3'>No content available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserDataReview
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
This service file contains functions for retrieving an IPFS hash from the
|
||||
BCH blockchain, in a fasion similar to PS001:
|
||||
https://github.com/Permissionless-Software-Foundation/specifications/blob/master/ps001-media-sharing.md
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import BchMessage from 'bch-message-lib'
|
||||
|
||||
// Local libraries
|
||||
import AppUtil from '../../util'
|
||||
|
||||
class Memo {
|
||||
constructor (config) {
|
||||
this.config = config
|
||||
|
||||
// Encapsulate dependencies
|
||||
this.util = new AppUtil()
|
||||
}
|
||||
|
||||
// Instantiate the bch-message-lib library.
|
||||
async initialize (wallet) {
|
||||
try {
|
||||
// Throw an error if this class is instantiated without passing a BCH address.
|
||||
if (!this.config || !this.config.bchAddr) {
|
||||
throw new Error('Must pass a BCH address to Memo constructor.')
|
||||
} else {
|
||||
this.bchAddr = this.config.bchAddr
|
||||
}
|
||||
|
||||
this.wallet = wallet
|
||||
|
||||
this.bchMessage = new BchMessage({ wallet })
|
||||
} catch (err) {
|
||||
console.error('Error in get-cid.js/initialize(): ', err.message)
|
||||
// console.log('Waiting 5 seconds before trying again.')
|
||||
// await this.util.sleep(5000)
|
||||
// this.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the transactions associated with an address until a proper IPFS hash is
|
||||
// found. If one is not found, will return false.
|
||||
async findHash () {
|
||||
try {
|
||||
console.log(`Finding latest IPFS hash for address: ${this.bchAddr}...`)
|
||||
|
||||
const txs = await this.bchMessage.memo.memoRead(
|
||||
this.bchAddr,
|
||||
'IPFS UPDATE'
|
||||
)
|
||||
// console.log(`txs: ${JSON.stringify(txs, null, 2)}`)
|
||||
|
||||
// If the array is empty, then return false.
|
||||
if (txs.length === 0) return false
|
||||
|
||||
const hash = txs[0].subject
|
||||
console.log(`...found this IPFS hash: ${hash}`)
|
||||
|
||||
// The transactions should automatically be sorted by the bchMessage
|
||||
// library. So Just return the subject.
|
||||
return hash
|
||||
} catch (err) {
|
||||
console.warn('Could not find IPFS hash in transaction history.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Memo
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
A footer section for the SPA
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
import { Container, Row, Col } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import config from '../../config'
|
||||
// import Memo from './get-cid'
|
||||
|
||||
function Footer (props) {
|
||||
// const [ipfsCid, setIpfsCid] = useState(config.ipfsCid)
|
||||
const wallet = props.appData.wallet
|
||||
|
||||
// Retrieve the most up-to-date CID for the app on Filecoin from the BCH blockchain.
|
||||
useEffect(() => {
|
||||
async function fetchData () {
|
||||
try {
|
||||
// const hash = await getUpdatedUrl(wallet)
|
||||
// if (hash) {
|
||||
// setIpfsCid(hash)
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error('Error trying to retrieve Filecoin CID for the app from the BCH blockchain.')
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [wallet])
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: '#ddd' }}>
|
||||
<Row style={{ padding: '25px' }}>
|
||||
<Col>
|
||||
<h6>Source Code</h6>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={config.ghRepo} target='_blank' rel='noreferrer'>GitHub</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</Col>
|
||||
|
||||
<Col />
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// async function getUpdatedUrl (wallet) {
|
||||
// try {
|
||||
// // Exit if the wallet is not initialized.
|
||||
// if (!wallet) return
|
||||
|
||||
// // Initialize the memo library for retrieving data from the BCH blockchain.
|
||||
// const memo = new Memo({ bchAddr: config.appBchAddr })
|
||||
// await memo.initialize(wallet)
|
||||
// const hash = await memo.findHash()
|
||||
|
||||
// if (!hash) {
|
||||
// console.error(
|
||||
// `Could not find IPFS hash in transactions for address ${config.appBchAddr}`
|
||||
// )
|
||||
// return false
|
||||
// }
|
||||
// // console.log(`latest IPFS hash: ${hash}`)
|
||||
|
||||
// return hash
|
||||
// } catch (err) {
|
||||
// console.log('Error in getUpdatedUrl(): ', err)
|
||||
// }
|
||||
// }
|
||||
|
||||
export default Footer
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Load <script> libraries
|
||||
*/
|
||||
|
||||
import useScript from '../hooks/use-script'
|
||||
|
||||
function LoadScripts () {
|
||||
// useScript('https://unpkg.com/minimal-slp-wallet?module')
|
||||
|
||||
// Load the libraries from the local directory.
|
||||
useScript(`${process.env.PUBLIC_URL}/minimal-slp-wallet.min.js`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export default LoadScripts
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Navigation menu for PSFFPP Payments UI.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Nav, Navbar, Image } from 'react-bootstrap'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import Logo from './psf-logo.png'
|
||||
|
||||
function linkClass (currentPath, paths) {
|
||||
const list = Array.isArray(paths) ? paths : [paths]
|
||||
return list.includes(currentPath) ? 'nav-link-active' : 'nav-link-inactive'
|
||||
}
|
||||
|
||||
function NavMenu (props) {
|
||||
const { currentPath } = props.appData
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const handleClickEvent = () => {
|
||||
setExpanded(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar expanded={expanded} onToggle={setExpanded} expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
|
||||
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
|
||||
<Image src={Logo} thumbnail width='50' />{' '}
|
||||
PSFFPP Payments
|
||||
</Navbar.Brand>
|
||||
|
||||
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
|
||||
<Navbar.Collapse id='responsive-navbar-nav'>
|
||||
<Nav className='mr-auto'>
|
||||
<NavLink
|
||||
className={linkClass(currentPath, ['/bch', '/'])}
|
||||
to='/bch'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
BCH
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/wallet')}
|
||||
to='/wallet'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Wallet
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/sign')}
|
||||
to='/sign'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Sign
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/contracts')}
|
||||
to='/contracts'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Contracts
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/pool')}
|
||||
to='/pool'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Pool
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/consolidate')}
|
||||
to='/consolidate'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Consolidate
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/split')}
|
||||
to='/split'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Split
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/claim/cosign')}
|
||||
to='/claim/cosign'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Cosign
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/claim')}
|
||||
to='/claim'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Claim
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={linkClass(currentPath, '/configuration')}
|
||||
to='/configuration'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Configuration
|
||||
</NavLink>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavMenu
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* This file contains the views that are displayed before and after the BCH wallet is initialized.
|
||||
*/
|
||||
import React from 'react'
|
||||
import WaitingModal from './waiting-modal'
|
||||
import AppBody from './app-body'
|
||||
// This is rendered *before* the BCH wallet is initialized.
|
||||
export function UninitializedView (props = {}) {
|
||||
// console.log('UninitializedView props: ', props)
|
||||
const { appData } = props
|
||||
|
||||
const heading = 'Connecting to BCH blockchain...'
|
||||
|
||||
return (
|
||||
<>
|
||||
<WaitingModal
|
||||
heading={heading}
|
||||
body={appData.modalBody}
|
||||
hideSpinner={appData.hideSpinner}
|
||||
denyClose={appData.denyClose}
|
||||
/>
|
||||
{
|
||||
appData.asyncInitFinished
|
||||
? <> <br /><AppBody menuState={100} wallet={appData.wallet} appData={appData} /></>
|
||||
: null
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// This is rendered *after* the BCH wallet is initialized.
|
||||
export function InitializedView (props) {
|
||||
const { appData } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<br />
|
||||
<AppBody menuState={appData.menuState} appData={appData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
This 'Waiting Modal' component displays a spinner animation and a status log.
|
||||
It's used to inform the user that the app is waiting for something, and to
|
||||
display progress.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Modal, Spinner } from 'react-bootstrap'
|
||||
|
||||
function ModalTemplate (props) {
|
||||
// State
|
||||
const [show, setShow] = useState(true)
|
||||
|
||||
// Dependency injection of props
|
||||
const denyClose = props.denyClose // Determins if user is allowed to close modal.
|
||||
const closeFunc = props.closeFunc // Optional function called after modal is closed.
|
||||
const heading = props.heading // Title of the modal
|
||||
const body = props.body // Body of the modal
|
||||
const hideSpinner = props.hideSpinner // Hide the animated spinner
|
||||
|
||||
// This function is called when the modal is closed
|
||||
const handleClose = () => {
|
||||
console.log(`props.denyClose: ${denyClose}`)
|
||||
if (denyClose) return
|
||||
|
||||
setShow(false)
|
||||
|
||||
if (closeFunc) {
|
||||
closeFunc()
|
||||
}
|
||||
}
|
||||
// const handleShow = () => setShow(true)
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{heading}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col style={{ textAlign: 'center' }}>
|
||||
<BodyList body={body} />
|
||||
{hideSpinner ? null : <Spinner animation='border' />}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
<Modal.Footer />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// This function populates the body of the modal. It expects props.body to be
|
||||
// an array of strings.
|
||||
function BodyList (props) {
|
||||
const items = props.body
|
||||
// console.log('BodyList items: ', items)
|
||||
|
||||
const listItems = []
|
||||
|
||||
// Paragraphs
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
listItems.push(<p key={items[i]}><code>{items[i]}</code></p>)
|
||||
}
|
||||
|
||||
return (
|
||||
listItems
|
||||
)
|
||||
}
|
||||
|
||||
// export default WaitingModal
|
||||
export default ModalTemplate
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Global configuration settings for this app.
|
||||
*/
|
||||
|
||||
const config = {
|
||||
// Default IPFS CID for the app. This will be overwritten by dynamic lookup.
|
||||
ipfsCid: 'bafybeibya4cwro6rgqfaazqxckcchy6qo5sz2aqc4dx7ptcvpns5peqcz4',
|
||||
|
||||
// BCH address used to point to the latest version of the IPFS CID.
|
||||
appBchAddr: 'bitcoincash:qztv87ppjh82v527jq2drp4u8rzzn63r5cmhth2zzm',
|
||||
|
||||
// Backup Info that goes into the Footer.
|
||||
ghPagesUrl: 'https://permissionless-software-foundation.github.io/react-bootstrap-web3-spa/',
|
||||
ghRepo: 'https://github.com/Permissionless-Software-Foundation/bch-wallet-web3-spa',
|
||||
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc'
|
||||
|
||||
}
|
||||
|
||||
export default config
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"contractName": "ShareContract",
|
||||
"constructorInputs": [
|
||||
{
|
||||
"name": "node0",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "node1",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "node2",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "nodePkh",
|
||||
"type": "bytes20"
|
||||
}
|
||||
],
|
||||
"abi": [
|
||||
{
|
||||
"name": "claim",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "s0",
|
||||
"type": "sig"
|
||||
},
|
||||
{
|
||||
"name": "s1",
|
||||
"type": "sig"
|
||||
},
|
||||
{
|
||||
"name": "s2",
|
||||
"type": "sig"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bytecode": "OP_TXOUTPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_0 OP_5 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_4 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_3 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_2 OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTBYTECODE 76a914 OP_ROT OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_0 OP_0 OP_BEGIN OP_DUP OP_TXINPUTCOUNT OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_2DUP OP_UTXOVALUE OP_ADD OP_ROT OP_DROP OP_SWAP OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP OP_0 OP_OUTPUTVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTVALUE OP_0 OP_GREATERTHAN",
|
||||
"source": "pragma cashscript ^0.13.0;\n\n////////////////////////////////////////////////////////////////////////////////\n// ShareContract — one instance per node (M=3, K=2)\n//\n// Holds one node's monthly share UTXO until K-of-M operators approve claim.\n//\n// Operation: claim\n// inputs:\n// 0 ShareContract [BCH] (this share UTXO)\n// 1? feePayer [BCH] (optional P2PKH fee input)\n// outputs:\n// 0 nodePayout [BCH] (P2PKH to this node's nodePkh)\n////////////////////////////////////////////////////////////////////////////////\n\ncontract ShareContract(\n pubkey node0,\n pubkey node1,\n pubkey node2,\n bytes20 nodePkh\n) {\n function claim(sig s0, sig s1, sig s2) {\n // CRITICAL: limit outputs first\n require(tx.outputs.length == 1, \"claim: expected exactly 1 output\");\n\n // K-of-M (K=2): empty sig (0x) returns false; never pass invalid non-empty sigs (NULLFAIL)\n int validCount = 0;\n if (checkSig(s0, node0)) {\n validCount = validCount + 1;\n }\n if (checkSig(s1, node1)) {\n validCount = validCount + 1;\n }\n if (checkSig(s2, node2)) {\n validCount = validCount + 1;\n }\n require(validCount >= 2, \"claim: need at least 2-of-3 signatures\");\n\n // Terminating spend to this node's payout address (no self-replication)\n require(\n tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(nodePkh),\n \"claim: output must pay nodePkh\"\n );\n require(tx.outputs[0].tokenCategory == 0x, \"claim: output must be pure BCH\");\n\n // Value conservation (fee = inputs - output)\n int inSum = 0;\n for (int i = 0; i < tx.inputs.length; i = i + 1) {\n inSum = inSum + tx.inputs[i].value;\n }\n require(tx.outputs[0].value <= inSum, \"claim: value not conserved\");\n require(tx.outputs[0].value > 0, \"claim: zero payout\");\n }\n}\n",
|
||||
"fingerprint": "2937963007e829c882ed2cfd37948691c196d68c52c90a9d75f265a7b3b17a45",
|
||||
"debug": {
|
||||
"bytecode": "c4519d00557a7bac63768b7768547a7bac63768b7768537a7bac63768b776852a26900cd0376a9147b7e0288ac7e8800d1008800006576c39f766b636ec6937b757c768b77686c91667500cca26900cc00a0",
|
||||
"sourceMap": "24:16:24:33;:37::38;:8::76:1;27:25:27:26:0;28:21:28:23;;:25::30;:12::31:1;:33:30:9:0;29:25:29:35;:::39:1;:12::40;28:33:30:9;31:21:31:23:0;;:25::30;:12::31:1;:33:33:9:0;32:25:32:35;:::39:1;:12::40;31:33:33:9;34:21:34:23:0;;:25::30;:12::31:1;:33:36:9:0;35:25:35:35;:::39:1;:12::40;34:33:36:9;37:30:37:31:0;:16:::1;:8::75;41:23:41:24:0;:12::41:1;:45::78:0;:70::77;:45::78:1;;;40:8:43:10;44:27:44:28:0;:16::43:1;:47::49:0;:8::85:1;47:20:47:21:0;48:21:48:22;:8:50:9;:24:48:25;:28::44;:24:::1;;;:57:50:9:0;49:20:49:39;:28::46:1;:20;:12::47;;;48:50:48:51:0;:::55:1;:46;:57:50:9;;:8;;;51:27:51:28:0;:16::35:1;:::44;:8::76;52:27:52:28:0;:16::35:1;:38::39:0;:8::63:1",
|
||||
"logs": [],
|
||||
"requires": [
|
||||
{
|
||||
"ip": 6,
|
||||
"line": 24,
|
||||
"message": "claim: expected exactly 1 output"
|
||||
},
|
||||
{
|
||||
"ip": 37,
|
||||
"line": 37,
|
||||
"message": "claim: need at least 2-of-3 signatures"
|
||||
},
|
||||
{
|
||||
"ip": 45,
|
||||
"line": 40,
|
||||
"message": "claim: output must pay nodePkh"
|
||||
},
|
||||
{
|
||||
"ip": 49,
|
||||
"line": 44,
|
||||
"message": "claim: output must be pure BCH"
|
||||
},
|
||||
{
|
||||
"ip": 76,
|
||||
"line": 51,
|
||||
"message": "claim: value not conserved"
|
||||
},
|
||||
{
|
||||
"ip": 81,
|
||||
"line": 52,
|
||||
"message": "claim: zero payout"
|
||||
}
|
||||
],
|
||||
"sourceTags": "61:63:fu;64:67:lc;68:68:sc"
|
||||
},
|
||||
"compiler": {
|
||||
"name": "cashc",
|
||||
"version": "0.13.2",
|
||||
"options": {
|
||||
"enforceFunctionParameterTypes": true,
|
||||
"enforceLocktimeGuard": true
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-08-09T23:37:20.852Z"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"network": "mainnet",
|
||||
"minConsolidation": 100000,
|
||||
"splitBlockheight": 920000,
|
||||
"treasuryPkh": "0000000000000000000000000000000000000000",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "node0",
|
||||
"pubkey": "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pkh": "1111111111111111111111111111111111111111"
|
||||
},
|
||||
{
|
||||
"name": "node1",
|
||||
"pubkey": "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pkh": "2222222222222222222222222222222222222222"
|
||||
},
|
||||
{
|
||||
"name": "node2",
|
||||
"pubkey": "02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"pkh": "3333333333333333333333333333333333333333"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
// import { useQueryParam, StringParam } from 'use-query-params'
|
||||
import useLocalStorageState from 'use-local-storage-state'
|
||||
import AppUtil from '../util'
|
||||
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
function useAppState () {
|
||||
const location = useLocation()
|
||||
|
||||
// Load Local storage Data
|
||||
const [lsState, setLSState, { removeItem }] = useLocalStorageState('psffpp-payments-ui', {
|
||||
ssr: true,
|
||||
defaultValue: {
|
||||
serverUrl: 'https://free-bch.fullstack.cash' // Default server
|
||||
}
|
||||
})
|
||||
|
||||
// Federation deploy config (no private keys). Shape matches deploy.mainnet.example.json.
|
||||
const [deployConfig, setDeployConfig] = useLocalStorageState('psffppDeployConfig', {
|
||||
ssr: true,
|
||||
defaultValue: null
|
||||
})
|
||||
|
||||
console.log('lsState: ', lsState)
|
||||
|
||||
// Initialize data states
|
||||
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
|
||||
const [menuState, setMenuState] = useState(0)
|
||||
const [wallet, setWallet] = useState(false)
|
||||
const [servers, setServers] = useState([])
|
||||
|
||||
// Startup state management
|
||||
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
|
||||
const [asyncInitFinished, setAsyncInitFinished] = useState(false)
|
||||
const [asyncInitSucceeded, setAsyncInitSucceeded] = useState(null)
|
||||
|
||||
// Modal state management
|
||||
const [showStartModal, setShowStartModal] = useState(true)
|
||||
const [modalBody, setModalBody] = useState([])
|
||||
const [hideSpinner, setHideSpinner] = useState(false)
|
||||
const [denyClose, setDenyClose] = useState(false)
|
||||
|
||||
const [isSingleView, setIsSingleView] = useState(false)
|
||||
// Background process state
|
||||
const [asyncBackGroundInitState, setAsyncBackGroundInitState] = useState({
|
||||
bchInitLoaded: false, slpInitLoaded: false, asyncBackgroundFinished: false
|
||||
})
|
||||
|
||||
// The wallet state makes this a true progressive web app (PWA). As
|
||||
// balances, UTXOs, and tokens are retrieved, this state is updated.
|
||||
// properties are enumerated here for the purpose of documentation.
|
||||
|
||||
// Local storage
|
||||
// const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState', {
|
||||
// ssr: true,
|
||||
// defaultValue: {}
|
||||
// })
|
||||
// console.log('lsState: ', lsState)
|
||||
const removeLocalStorageItem = removeItem
|
||||
const updateLocalStorage = (lsObj) => {
|
||||
// console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
|
||||
|
||||
// Progressively overwrite the LocalStorage state.
|
||||
const newObj = Object.assign({}, lsState, lsObj)
|
||||
// console.log(`updateLocalStorage() output: ${JSON.stringify(newObj, null, 2)}`)
|
||||
|
||||
setLSState(newObj)
|
||||
}
|
||||
|
||||
const [bchWalletState, setBchWalletState] = useState({
|
||||
mnemonic: undefined,
|
||||
address: undefined,
|
||||
cashAddress: undefined,
|
||||
slpAddress: undefined,
|
||||
privateKey: undefined,
|
||||
publicKey: undefined,
|
||||
legacyAddress: undefined,
|
||||
hdPath: undefined,
|
||||
bchBalance: 0,
|
||||
slpTokens: [],
|
||||
bchUsdPrice: 150
|
||||
})
|
||||
|
||||
// This function is passed to child components in order to update the wallet
|
||||
// state. This function is important to make this wallet a PWA.
|
||||
function updateBchWalletState (inObj = {}) {
|
||||
try {
|
||||
const { walletObj, appData } = inObj
|
||||
|
||||
// Debuging
|
||||
// console.log('updateBchWalletState() walletObj: ', walletObj)
|
||||
// console.log('updateBchWalletState() appData: ', appData)
|
||||
|
||||
appData.setBchWalletState(oldState => {
|
||||
const newBchWalletState = Object.assign({}, oldState, walletObj)
|
||||
// console.log('newBchWalletState: ', newBchWalletState)
|
||||
|
||||
return newBchWalletState
|
||||
})
|
||||
|
||||
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
|
||||
} catch (err) {
|
||||
console.error('Error in App.js updateBchWalletState()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update background state
|
||||
function updateBackGroundInitState (inObj = {}) {
|
||||
try {
|
||||
setAsyncBackGroundInitState(oldState => {
|
||||
// console.log('background old state: ', oldState)
|
||||
|
||||
const state = Object.assign({}, oldState, inObj)
|
||||
// console.log('background state: ', state)
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
|
||||
} catch (err) {
|
||||
console.error('Error in App.js updateBackGroundInitState()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
setServerUrl,
|
||||
menuState,
|
||||
setMenuState,
|
||||
wallet,
|
||||
setWallet,
|
||||
servers,
|
||||
setServers,
|
||||
asyncInitStarted,
|
||||
setAsyncInitStarted,
|
||||
asyncInitFinished,
|
||||
setAsyncInitFinished,
|
||||
asyncInitSucceeded,
|
||||
setAsyncInitSucceeded,
|
||||
showStartModal,
|
||||
setShowStartModal,
|
||||
modalBody,
|
||||
setModalBody,
|
||||
hideSpinner,
|
||||
setHideSpinner,
|
||||
denyClose,
|
||||
setDenyClose,
|
||||
bchWalletState,
|
||||
setBchWalletState,
|
||||
lsState,
|
||||
setLSState,
|
||||
removeLocalStorageItem,
|
||||
updateLocalStorage,
|
||||
updateBchWalletState,
|
||||
appUtil: new AppUtil(),
|
||||
currentPath: location.pathname,
|
||||
setIsSingleView,
|
||||
isSingleView,
|
||||
asyncBackGroundInitState,
|
||||
updateBackGroundInitState,
|
||||
deployConfig,
|
||||
setDeployConfig
|
||||
}
|
||||
}
|
||||
|
||||
export default useAppState
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const useScript = url => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script')
|
||||
|
||||
script.src = url
|
||||
script.async = true
|
||||
|
||||
document.body.appendChild(script)
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script)
|
||||
}
|
||||
}, [url])
|
||||
}
|
||||
|
||||
export default useScript
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { QueryParamProvider } from 'use-query-params'
|
||||
|
||||
// Importing the Bootstrap CSS
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'))
|
||||
|
||||
root.render(
|
||||
<QueryParamProvider>
|
||||
{/* <BrowserRouter> should be wrap all the components that use react-router-dom */}
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryParamProvider>
|
||||
)
|
||||
|
||||
// Updating to React v18
|
||||
// https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
This library gets data that requires an async wait.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import axios from 'axios'
|
||||
// Local libraries
|
||||
import GistServers from './gist-servers'
|
||||
|
||||
class AsyncLoad {
|
||||
constructor () {
|
||||
this.BchWallet = false
|
||||
}
|
||||
|
||||
// Load the minimal-slp-wallet which comes in as a <script> file and is
|
||||
// attached to the global 'window' object.
|
||||
async loadWalletLib () {
|
||||
try {
|
||||
do {
|
||||
if (typeof window !== 'undefined' && window.SlpWallet) {
|
||||
this.BchWallet = window.SlpWallet
|
||||
|
||||
return this.BchWallet
|
||||
} else {
|
||||
console.log('Waiting for wallet library to load...')
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
} while (!this.BchWallet)
|
||||
} catch (error) {
|
||||
console.error('Error loading wallet library: ', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the BCH wallet
|
||||
async initWallet (restURL, mnemonic, appData) {
|
||||
try {
|
||||
const options = {
|
||||
interface: 'consumer-api',
|
||||
restURL,
|
||||
noUpdate: true
|
||||
}
|
||||
|
||||
let wallet
|
||||
if (mnemonic) {
|
||||
// Load the wallet from the mnemonic, if it's available from local storage.
|
||||
wallet = new this.BchWallet(mnemonic, options)
|
||||
} else {
|
||||
// Generate a new mnemonic and wallet.
|
||||
wallet = new this.BchWallet(null, options)
|
||||
}
|
||||
|
||||
// Wait for wallet to initialize.
|
||||
await wallet.walletInfoPromise
|
||||
await wallet.initialize()
|
||||
console.log('starting to update wallet state.')
|
||||
// Update the state of the wallet.
|
||||
appData.updateBchWalletState({ walletObj: wallet.walletInfo, appData })
|
||||
console.log('finished updating wallet state.')
|
||||
// Save the mnemonic to local storage.
|
||||
if (!mnemonic) {
|
||||
const newMnemonic = wallet.walletInfo.mnemonic
|
||||
appData.updateLocalStorage({ mnemonic: newMnemonic })
|
||||
}
|
||||
|
||||
this.wallet = wallet
|
||||
|
||||
return wallet
|
||||
} catch (error) {
|
||||
console.error('Error initializing wallet: ', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get the BCH balance of the wallet.
|
||||
async getWalletBchBalance (wallet, updateBchWalletState, appData) {
|
||||
try {
|
||||
/* // Force error for development
|
||||
await sleep(6000)
|
||||
throw new Error('getWalletBchBalance error')
|
||||
*/
|
||||
|
||||
// Get the BCH balance of the wallet.
|
||||
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
|
||||
|
||||
await wallet.walletInfoPromise
|
||||
await wallet.initialize()
|
||||
// console.log(`mnemonic: ${wallet.walletInfo.mnemonic}`)
|
||||
|
||||
// Update the state of the wallet with the balances
|
||||
updateBchWalletState({ walletObj: { bchBalance }, appData })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error getting wallet BCH balance: ', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get the spot exchange rate for BCH in USD.
|
||||
async getUSDExchangeRate (wallet, updateBchWalletState, appData) {
|
||||
try {
|
||||
const bchUsdPrice = await wallet.getUsd()
|
||||
|
||||
// Update the state of the wallet
|
||||
updateBchWalletState({ walletObj: { bchUsdPrice }, appData })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error getting USD exchange rate: ', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get a list of SLP tokens held by the wallet.
|
||||
async getSlpTokenBalances (wallet, updateBchWalletState, appData) {
|
||||
try {
|
||||
/* // Force error for development
|
||||
await sleep(6000)
|
||||
throw new Error('getSlpTokenBalances error')
|
||||
*/
|
||||
// Get token information from the wallet. This will also initialize the UTXO store.
|
||||
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
|
||||
// console.log('slpTokens: ', slpTokens)
|
||||
|
||||
console.log('slpTokens: ', slpTokens)
|
||||
|
||||
// Update the state of the wallet with the balances
|
||||
updateBchWalletState({ walletObj: { slpTokens }, appData })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error getting SLP tokens', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get token data for a given Token ID
|
||||
async getTokenData (tokenId) {
|
||||
try {
|
||||
const tokenData = await this.wallet.getTokenData(tokenId)
|
||||
|
||||
// Convert the IPFS CIDs into actual data.
|
||||
tokenData.immutableData = await this.getIpfsData(tokenData.immutableData)
|
||||
tokenData.mutableData = await this.getIpfsData(tokenData.mutableData)
|
||||
|
||||
return tokenData
|
||||
} catch (error) {
|
||||
console.error('Error getting Token data', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get data about a Group token
|
||||
async getGroupData (tokenId) {
|
||||
try {
|
||||
const tokenData = await this.getTokenData(tokenId)
|
||||
|
||||
const groupData = {
|
||||
immutableData: tokenData.immutableData,
|
||||
mutableData: tokenData.mutableData,
|
||||
nfts: tokenData.genesisData.nfts,
|
||||
tokenId: tokenData.genesisData.tokenId
|
||||
}
|
||||
|
||||
return groupData
|
||||
} catch (error) {
|
||||
console.error('Error getting Token group data', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Given an IPFS URI, this will download and parse the JSON data.
|
||||
async getIpfsData (ipfsUri) {
|
||||
try {
|
||||
const cid = ipfsUri.slice(7)
|
||||
|
||||
const downloadUrl = `https://${cid}.ipfs.dweb.link/data.json`
|
||||
|
||||
const response = await axios.get(downloadUrl)
|
||||
const data = response.data
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error getting IPFS data', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get a list of alternative back end servers.
|
||||
async getServers () {
|
||||
// Try to get the list from GitHub
|
||||
try {
|
||||
const gistLib = new GistServers()
|
||||
const gistServers = await gistLib.getServerList()
|
||||
|
||||
return gistServers
|
||||
} catch (err) {
|
||||
console.error('Error trying to retrieve list of servers from GitHub: ', err)
|
||||
console.log('Returning hard-coded list of servers.')
|
||||
|
||||
const defaultOptions = [
|
||||
{ value: 'https://free-bch.fullstack.cash', label: 'https://free-bch.fullstack.cash' },
|
||||
{ value: 'https://dev-consumer.psfoundation.info', label: 'https://dev-consumer.psfoundation.info' }
|
||||
]
|
||||
|
||||
return defaultOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the BCH wallet without initialize() promise
|
||||
async initStarterWallet (restURL, mnemonic, appData) {
|
||||
try {
|
||||
const options = {
|
||||
interface: 'consumer-api',
|
||||
restURL,
|
||||
noUpdate: true
|
||||
}
|
||||
|
||||
let wallet
|
||||
if (mnemonic) {
|
||||
// Load the wallet from the mnemonic, if it's available from local storage.
|
||||
wallet = new this.BchWallet(mnemonic, options)
|
||||
} else {
|
||||
// Generate a new mnemonic and wallet.
|
||||
wallet = new this.BchWallet(null, options)
|
||||
}
|
||||
|
||||
await wallet.walletInfoPromise
|
||||
|
||||
const walletInfo = wallet.walletInfo
|
||||
// Update the state of the wallet.
|
||||
appData.updateBchWalletState({ walletObj: walletInfo, appData })
|
||||
// Save the mnemonic to local storage.
|
||||
if (!mnemonic) {
|
||||
const newMnemonic = wallet.walletInfo.mnemonic
|
||||
appData.updateLocalStorage({ mnemonic: newMnemonic })
|
||||
}
|
||||
|
||||
this.wallet = wallet
|
||||
|
||||
return wallet
|
||||
} catch (error) {
|
||||
console.error('Error initStarterWallet: ', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep (ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export default AsyncLoad
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
This library downloads a dynamic list of back-end servers from a GitHub Gist.
|
||||
*/
|
||||
|
||||
const axios = require('axios')
|
||||
|
||||
class GistServers {
|
||||
constructor () {
|
||||
this.axios = axios
|
||||
}
|
||||
|
||||
// Retrieve a JSON file from a GitHub Gist
|
||||
async getServerList () {
|
||||
try {
|
||||
// https://gist.github.com/christroutner/e818ecdaed6c35075bfc0751bf222258
|
||||
// 'https://api.github.com/gists/63c5513782181f8b8ea3eb89f7cadeb6'
|
||||
|
||||
const gistUrl = 'https://consumers.psfoundation.info/consumers.json'
|
||||
|
||||
// Retrieve the gist from github.com.
|
||||
const result = await this.axios.get(gistUrl)
|
||||
// console.log('result.data: ', result.data)
|
||||
|
||||
// Get the current content of the gist.
|
||||
const content = result.data.servers
|
||||
// console.log('content: ', content)
|
||||
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error('Error in getCRList()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GistServers
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Address / PKH helpers for PSFFPP UI.
|
||||
*/
|
||||
import { encodeCashAddress, hexToBin, binToHex } from '@bitauth/libauth'
|
||||
|
||||
export function pkhToCashAddr (pkhHex, network) {
|
||||
const prefix = network === 'mainnet' || !network ? 'bitcoincash' : 'bchtest'
|
||||
return encodeCashAddress({
|
||||
prefix,
|
||||
type: 'p2pkh',
|
||||
payload: hexToBin(pkhHex),
|
||||
throwErrors: true
|
||||
}).address
|
||||
}
|
||||
|
||||
export function toCashScriptUtxos (walletUtxoPayload) {
|
||||
const list = walletUtxoPayload?.bchUtxos || []
|
||||
return list.map((u, i) => {
|
||||
const txid = u.txid || u.tx_hash
|
||||
const vout = u.vout ?? u.tx_pos
|
||||
const satoshis = BigInt(u.satoshis ?? u.value ?? 0)
|
||||
if (!txid || vout === undefined || satoshis <= 0n) {
|
||||
throw new Error(`Malformed fee UTXO at index ${i}: ${JSON.stringify(u)}`)
|
||||
}
|
||||
return { txid, vout, satoshis }
|
||||
})
|
||||
}
|
||||
|
||||
export { binToHex, hexToBin }
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Assemble ≥2 claim-sig JSON envelopes and broadcast ShareContract.claim.
|
||||
*/
|
||||
import { TransactionBuilder } from 'cashscript'
|
||||
import { hexToBin } from '@bitauth/libauth'
|
||||
import {
|
||||
createProvider,
|
||||
instantiateContracts
|
||||
} from './lib'
|
||||
|
||||
const EMPTY_SIG = Uint8Array.of()
|
||||
|
||||
function claimIdentityKey (claim) {
|
||||
return JSON.stringify({
|
||||
shareIndex: claim.shareIndex,
|
||||
shareAddress: claim.shareAddress,
|
||||
shareUtxo: claim.shareUtxo,
|
||||
payoutAddress: claim.payoutAddress,
|
||||
payoutSatoshis: claim.payoutSatoshis,
|
||||
feeSatoshis: claim.feeSatoshis,
|
||||
feeMode: claim.feeMode,
|
||||
sighash: claim.sighash,
|
||||
signatureAlgorithm: claim.signatureAlgorithm,
|
||||
constructorPubkeys: claim.constructorPubkeys
|
||||
})
|
||||
}
|
||||
|
||||
export function parseClaimSigDoc (textOrObj) {
|
||||
const doc = typeof textOrObj === 'string' ? JSON.parse(textOrObj) : textOrObj
|
||||
if (doc.type !== 'psffpp-share-claim-sig') {
|
||||
throw new Error(`Expected type psffpp-share-claim-sig, got ${doc.type}`)
|
||||
}
|
||||
if (doc.version !== 1) {
|
||||
throw new Error(`Unsupported claim-sig version: ${doc.version}`)
|
||||
}
|
||||
if (!doc.claim || !doc.signer || !doc.signature) {
|
||||
throw new Error('Claim sig missing claim, signer, or signature')
|
||||
}
|
||||
if (!/^[0-9a-fA-F]+$/.test(doc.signature)) {
|
||||
throw new Error('signature must be hex')
|
||||
}
|
||||
if (!Number.isInteger(doc.signer.nodeIndex) || doc.signer.nodeIndex < 0 || doc.signer.nodeIndex > 2) {
|
||||
throw new Error('signer.nodeIndex must be 0, 1, or 2')
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export function validateClaimSigSet (docs) {
|
||||
if (!Array.isArray(docs) || docs.length < 2) {
|
||||
throw new Error('Need at least 2 claim signatures (K=2)')
|
||||
}
|
||||
|
||||
const parsed = docs.map((d) => parseClaimSigDoc(d))
|
||||
const key0 = claimIdentityKey(parsed[0].claim)
|
||||
for (let i = 1; i < parsed.length; i++) {
|
||||
if (claimIdentityKey(parsed[i].claim) !== key0) {
|
||||
throw new Error(
|
||||
`Claim parameters mismatch between signature files (index 0 vs ${i}). All cosigners must sign the same claim.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const slots = new Set()
|
||||
for (const doc of parsed) {
|
||||
if (slots.has(doc.signer.nodeIndex)) {
|
||||
throw new Error(`Duplicate signer.nodeIndex ${doc.signer.nodeIndex}`)
|
||||
}
|
||||
slots.add(doc.signer.nodeIndex)
|
||||
}
|
||||
|
||||
if (slots.size < 2) {
|
||||
throw new Error('Need signatures from at least 2 distinct nodes')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function broadcastClaim ({
|
||||
cfg,
|
||||
wallet,
|
||||
sigDocs,
|
||||
dryRun = false
|
||||
} = {}) {
|
||||
if (!cfg) throw new Error('Deploy config required')
|
||||
const parsed = validateClaimSigSet(sigDocs)
|
||||
const claim = parsed[0].claim
|
||||
|
||||
if (claim.shareIndex < 0 || claim.shareIndex > 2) {
|
||||
throw new Error('Invalid claim.shareIndex')
|
||||
}
|
||||
|
||||
// Ensure wallet pubkey set matches claim constructor pubs when config is loaded.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (cfg.nodes[i].pubkey.toLowerCase() !== claim.constructorPubkeys[i].toLowerCase()) {
|
||||
throw new Error(
|
||||
`Config nodes[${i}].pubkey does not match claim.constructorPubkeys[${i}]`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const network = cfg.network || 'mainnet'
|
||||
const provider = createProvider(network)
|
||||
const { shares } = instantiateContracts(cfg, provider)
|
||||
const share = shares[claim.shareIndex]
|
||||
|
||||
if (share.address !== claim.shareAddress) {
|
||||
throw new Error(
|
||||
`Derived share address ${share.address} !== claim.shareAddress ${claim.shareAddress}`
|
||||
)
|
||||
}
|
||||
|
||||
const shareUtxo = {
|
||||
txid: claim.shareUtxo.txid,
|
||||
vout: claim.shareUtxo.vout,
|
||||
satoshis: BigInt(claim.shareUtxo.satoshis)
|
||||
}
|
||||
const payout = BigInt(claim.payoutSatoshis)
|
||||
|
||||
const sigArgs = [EMPTY_SIG, EMPTY_SIG, EMPTY_SIG]
|
||||
for (const doc of parsed) {
|
||||
sigArgs[doc.signer.nodeIndex] = hexToBin(doc.signature)
|
||||
}
|
||||
|
||||
const builder = new TransactionBuilder({ provider })
|
||||
.addInput(shareUtxo, share.unlock.claim(...sigArgs))
|
||||
.addOutput({ to: claim.payoutAddress, amount: payout })
|
||||
|
||||
const hex = builder.build()
|
||||
const summary = {
|
||||
shareIndex: claim.shareIndex,
|
||||
shareAddress: claim.shareAddress,
|
||||
payoutAddress: claim.payoutAddress,
|
||||
payoutSatoshis: claim.payoutSatoshis,
|
||||
feeSatoshis: claim.feeSatoshis,
|
||||
signerSlots: parsed.map((d) => d.signer.nodeIndex),
|
||||
dryRun,
|
||||
hex
|
||||
}
|
||||
|
||||
if (dryRun) return summary
|
||||
|
||||
if (!wallet) throw new Error('Wallet required to broadcast')
|
||||
const txid = await wallet.broadcast({ hex })
|
||||
return { ...summary, txid }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Cosign ShareContract.claim — tx-bound ECDSA (NOT message signatures).
|
||||
*/
|
||||
import {
|
||||
TransactionBuilder,
|
||||
SignatureTemplate,
|
||||
HashType,
|
||||
SignatureAlgorithm
|
||||
} from 'cashscript'
|
||||
import { binToHex } from '@bitauth/libauth'
|
||||
import { pkhToCashAddr } from './address'
|
||||
import {
|
||||
createProvider,
|
||||
instantiateContracts,
|
||||
feeFromHex,
|
||||
DEFAULT_FEE_RATE
|
||||
} from './lib'
|
||||
|
||||
const SIGHASH = HashType.SIGHASH_ALL | HashType.SIGHASH_UTXOS
|
||||
const EMPTY_SIG = Uint8Array.of()
|
||||
|
||||
function claimArgsCapturing (signerSlot, template) {
|
||||
const captured = { signature: null }
|
||||
const orig = template.generateSignature.bind(template)
|
||||
template.generateSignature = (payload, bchForkId) => {
|
||||
const sig = orig(payload, bchForkId)
|
||||
captured.signature = sig
|
||||
return sig
|
||||
}
|
||||
const args = [EMPTY_SIG, EMPTY_SIG, EMPTY_SIG]
|
||||
args[signerSlot] = template
|
||||
const restore = () => {
|
||||
template.generateSignature = orig
|
||||
}
|
||||
return { args, captured, restore }
|
||||
}
|
||||
|
||||
function parseUtxoRef (ref) {
|
||||
const [txid, voutStr] = ref.split(':')
|
||||
const vout = Number(voutStr)
|
||||
if (!txid || !Number.isInteger(vout) || vout < 0) {
|
||||
throw new Error(`UTXO must be <txid>:<vout>, got ${ref}`)
|
||||
}
|
||||
return { txid, vout }
|
||||
}
|
||||
|
||||
export async function cosignClaim ({
|
||||
cfg,
|
||||
wallet,
|
||||
shareIndex,
|
||||
utxoRef = null,
|
||||
payoutSatoshis = null,
|
||||
feeRate = DEFAULT_FEE_RATE
|
||||
} = {}) {
|
||||
if (!cfg) throw new Error('Deploy config required')
|
||||
if (!wallet) throw new Error('Wallet required to cosign')
|
||||
if (!Number.isInteger(shareIndex) || shareIndex < 0 || shareIndex > 2) {
|
||||
throw new Error('shareIndex must be 0, 1, or 2')
|
||||
}
|
||||
|
||||
const network = cfg.network || 'mainnet'
|
||||
const provider = createProvider(network)
|
||||
const { shares } = instantiateContracts(cfg, provider)
|
||||
|
||||
const share = shares[shareIndex]
|
||||
const payoutPkh = cfg.nodes[shareIndex].pkh
|
||||
const payoutAddress = pkhToCashAddr(payoutPkh, network)
|
||||
|
||||
let utxos = (await share.getUtxos()).filter((u) => !u.token)
|
||||
if (utxoRef) {
|
||||
const ref = parseUtxoRef(utxoRef)
|
||||
utxos = utxos.filter((u) => u.txid === ref.txid && u.vout === ref.vout)
|
||||
if (utxos.length === 0) {
|
||||
throw new Error(`Share UTXO not found: ${utxoRef}`)
|
||||
}
|
||||
}
|
||||
if (utxos.length === 0) {
|
||||
throw new Error(`No share UTXOs found for share index ${shareIndex}`)
|
||||
}
|
||||
if (utxos.length > 1 && !utxoRef) {
|
||||
const list = utxos.map((u) => `${u.txid}:${u.vout} (${u.satoshis})`).join(', ')
|
||||
throw new Error(`Multiple share UTXOs — select one: ${list}`)
|
||||
}
|
||||
|
||||
const shareUtxo = utxos[0]
|
||||
const wif = wallet.walletInfo.privateKey
|
||||
const template = new SignatureTemplate(wif, SIGHASH, SignatureAlgorithm.ECDSA)
|
||||
const signerPubHex = binToHex(template.getPublicKey())
|
||||
|
||||
const signerSlot = cfg.nodes.findIndex(
|
||||
(n) => n.pubkey.toLowerCase() === signerPubHex.toLowerCase()
|
||||
)
|
||||
if (signerSlot < 0) {
|
||||
throw new Error(
|
||||
`Wallet pubkey ${signerPubHex} is not one of config.nodes[].pubkey — wrong key?`
|
||||
)
|
||||
}
|
||||
|
||||
let payout
|
||||
let fee
|
||||
let signatureHex
|
||||
|
||||
if (payoutSatoshis != null) {
|
||||
payout = BigInt(payoutSatoshis)
|
||||
if (payout <= 0n || payout >= shareUtxo.satoshis) {
|
||||
throw new Error(`Invalid payoutSatoshis ${payout} for share ${shareUtxo.satoshis}`)
|
||||
}
|
||||
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
||||
try {
|
||||
const hex = new TransactionBuilder({ provider })
|
||||
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
||||
.addOutput({ to: payoutAddress, amount: payout })
|
||||
.build()
|
||||
fee = shareUtxo.satoshis - payout
|
||||
const needed = feeFromHex(hex, feeRate)
|
||||
if (fee < needed) {
|
||||
throw new Error(
|
||||
`Payout leaves fee ${fee} but tx needs ~${needed} at feeRate ${feeRate}`
|
||||
)
|
||||
}
|
||||
signatureHex = binToHex(captured.signature)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
} else {
|
||||
fee = 800n
|
||||
payout = shareUtxo.satoshis - fee
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
||||
try {
|
||||
const hex = new TransactionBuilder({ provider })
|
||||
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
||||
.addOutput({ to: payoutAddress, amount: payout })
|
||||
.build()
|
||||
const needed = feeFromHex(hex, feeRate)
|
||||
const nextPayout = shareUtxo.satoshis - needed
|
||||
if (nextPayout <= 0n) {
|
||||
throw new Error(`Share ${shareUtxo.satoshis} too small to cover fee ${needed}`)
|
||||
}
|
||||
signatureHex = binToHex(captured.signature)
|
||||
fee = needed
|
||||
if (nextPayout === payout) break
|
||||
payout = nextPayout
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
{
|
||||
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
||||
try {
|
||||
new TransactionBuilder({ provider })
|
||||
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
||||
.addOutput({ to: payoutAddress, amount: payout })
|
||||
.build()
|
||||
signatureHex = binToHex(captured.signature)
|
||||
fee = shareUtxo.satoshis - payout
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureHex) {
|
||||
throw new Error('Failed to capture ECDSA transaction signature')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'psffpp-share-claim-sig',
|
||||
version: 1,
|
||||
network,
|
||||
claim: {
|
||||
shareIndex,
|
||||
shareName: cfg.nodes[shareIndex].name || `node${shareIndex}`,
|
||||
shareAddress: share.address,
|
||||
shareUtxo: {
|
||||
txid: shareUtxo.txid,
|
||||
vout: shareUtxo.vout,
|
||||
satoshis: shareUtxo.satoshis.toString()
|
||||
},
|
||||
payoutAddress,
|
||||
payoutSatoshis: payout.toString(),
|
||||
feeSatoshis: fee.toString(),
|
||||
feeMode: 'from_share',
|
||||
sighash: 'ALL|UTXOS',
|
||||
signatureAlgorithm: 'ECDSA',
|
||||
constructorPubkeys: cfg.nodes.map((n) => n.pubkey.toLowerCase())
|
||||
},
|
||||
signer: {
|
||||
nodeIndex: signerSlot,
|
||||
name: cfg.nodes[signerSlot].name || `node${signerSlot}`,
|
||||
pubkey: signerPubHex.toLowerCase(),
|
||||
cashAddress: wallet.walletInfo.cashAddress
|
||||
},
|
||||
signature: signatureHex,
|
||||
notes: [
|
||||
'This signature unlocks ShareContract.claim for the exact claim tx described above.',
|
||||
'Do not change payoutSatoshis, fee, or UTXO without re-collecting signatures.',
|
||||
'Unused claim slots must be empty bytes (0x), never invalid non-empty sigs.',
|
||||
'BitcoinCash.signMessageWithPrivKey message signatures are NOT valid here.'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function listShareUtxos (cfg, shareIndex) {
|
||||
// Convenience for UI preview — async wrapper
|
||||
return (async () => {
|
||||
const provider = createProvider(cfg.network || 'mainnet')
|
||||
const { shares } = instantiateContracts(cfg, provider)
|
||||
const utxos = (await shares[shareIndex].getUtxos()).filter((u) => !u.token)
|
||||
return {
|
||||
address: shares[shareIndex].address,
|
||||
utxos: utxos.map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
satoshis: u.satoshis.toString(),
|
||||
ref: `${u.txid}:${u.vout}`
|
||||
}))
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Permissionless pool consolidate. Fee deducted from pool.
|
||||
* Broadcast via minimal-slp-wallet when wallet is provided.
|
||||
*/
|
||||
import { TransactionBuilder } from 'cashscript'
|
||||
import {
|
||||
createProvider,
|
||||
instantiateContracts,
|
||||
summarizeUtxo,
|
||||
DEFAULT_FEE_RATE,
|
||||
DEFAULT_MAX_UTXOS
|
||||
} from './lib'
|
||||
|
||||
export async function consolidatePool ({
|
||||
cfg,
|
||||
wallet,
|
||||
feeRate = DEFAULT_FEE_RATE,
|
||||
maxUtxos = DEFAULT_MAX_UTXOS,
|
||||
dryRun = false
|
||||
} = {}) {
|
||||
if (!cfg) throw new Error('Deploy config required')
|
||||
const provider = createProvider(cfg.network || 'mainnet')
|
||||
const { pool } = instantiateContracts(cfg, provider)
|
||||
|
||||
let utxos = (await pool.getUtxos()).filter((u) => !u.token)
|
||||
if (utxos.length < 2) {
|
||||
throw new Error(`Need at least 2 pool UTXOs to consolidate (found ${utxos.length})`)
|
||||
}
|
||||
if (utxos.length > maxUtxos) {
|
||||
utxos = utxos.slice(0, maxUtxos)
|
||||
}
|
||||
|
||||
const inSum = utxos.reduce((s, u) => s + u.satoshis, 0n)
|
||||
const minConsolidation = BigInt(cfg.minConsolidation)
|
||||
|
||||
const builder = new TransactionBuilder({ provider })
|
||||
for (const u of utxos) {
|
||||
builder.addInput(u, pool.unlock.consolidate())
|
||||
}
|
||||
builder.addBchChangeOutputIfNeeded({ to: pool.address, feeRate })
|
||||
|
||||
if (builder.outputs.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly 1 pool output, got ${builder.outputs.length}. Surplus may be below dust after fee.`
|
||||
)
|
||||
}
|
||||
|
||||
const outAmount = builder.outputs[0].amount
|
||||
if (outAmount < minConsolidation) {
|
||||
throw new Error(
|
||||
`Consolidated output ${outAmount} would be below minConsolidation ${minConsolidation}`
|
||||
)
|
||||
}
|
||||
|
||||
const fee = inSum - outAmount
|
||||
const hex = builder.build()
|
||||
|
||||
const summary = {
|
||||
poolAddress: pool.address,
|
||||
inputCount: utxos.length,
|
||||
inSum: inSum.toString(),
|
||||
fee: fee.toString(),
|
||||
feeRate,
|
||||
outAmount: outAmount.toString(),
|
||||
minConsolidation: minConsolidation.toString(),
|
||||
dryRun,
|
||||
inputs: utxos.map(summarizeUtxo),
|
||||
hex
|
||||
}
|
||||
|
||||
if (dryRun) return summary
|
||||
|
||||
if (!wallet) throw new Error('Wallet required to broadcast')
|
||||
const txid = await wallet.broadcast({ hex })
|
||||
return { ...summary, txid }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { validateConfig, parseDeployConfigJson, createProvider, instantiateContracts, DEFAULT_FEE_RATE } from './lib'
|
||||
export { fetchPoolStatus } from './status'
|
||||
export { consolidatePool } from './consolidate'
|
||||
export { splitPool } from './split'
|
||||
export { cosignClaim, listShareUtxos } from './claim-cosign'
|
||||
export { parseClaimSigDoc, validateClaimSigSet, broadcastClaim } from './claim-broadcast'
|
||||
export { pkhToCashAddr } from './address'
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Browser-safe helpers for PSFFPP Pool/Share contracts.
|
||||
* Ported from scripts/lib.mjs (no Node fs/crypto).
|
||||
*/
|
||||
import { hexToBin } from '@bitauth/libauth'
|
||||
import { Contract, ElectrumNetworkProvider } from 'cashscript'
|
||||
import shareArtifact from '../../contracts/artifacts/ShareContract.json'
|
||||
import poolArtifact from '../../contracts/artifacts/PoolContract.json'
|
||||
|
||||
export const DEFAULT_FEE_RATE = 1.2
|
||||
export const DEFAULT_MAX_UTXOS = 50
|
||||
|
||||
export function validateConfig (cfg) {
|
||||
if (!cfg || typeof cfg !== 'object') {
|
||||
throw new Error('Config must be a JSON object')
|
||||
}
|
||||
if (!cfg.nodes || cfg.nodes.length !== 3) {
|
||||
throw new Error('config.nodes must contain exactly 3 entries (M=3)')
|
||||
}
|
||||
for (const [i, node] of cfg.nodes.entries()) {
|
||||
if (!/^[0-9a-fA-F]{66}$/.test(node.pubkey)) {
|
||||
throw new Error(`nodes[${i}].pubkey must be 33-byte compressed hex (66 chars)`)
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{40}$/.test(node.pkh)) {
|
||||
throw new Error(`nodes[${i}].pkh must be 20-byte hex (40 chars)`)
|
||||
}
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{40}$/.test(cfg.treasuryPkh)) {
|
||||
throw new Error('treasuryPkh must be 20-byte hex (40 chars)')
|
||||
}
|
||||
if (!Number.isInteger(cfg.minConsolidation) || cfg.minConsolidation < 1) {
|
||||
throw new Error('minConsolidation must be a positive integer')
|
||||
}
|
||||
if (!Number.isInteger(cfg.splitBlockheight) || cfg.splitBlockheight < 1) {
|
||||
throw new Error('splitBlockheight must be a positive integer (block height)')
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
export function parseDeployConfigJson (text) {
|
||||
let cfg
|
||||
try {
|
||||
cfg = JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON: ${err.message}`)
|
||||
}
|
||||
return validateConfig(cfg)
|
||||
}
|
||||
|
||||
export function createProvider (network) {
|
||||
return new ElectrumNetworkProvider(network || 'mainnet')
|
||||
}
|
||||
|
||||
export function instantiateContracts (cfg, provider) {
|
||||
const pubs = cfg.nodes.map((n) => hexToBin(n.pubkey))
|
||||
const shares = cfg.nodes.map((node) =>
|
||||
new Contract(
|
||||
shareArtifact,
|
||||
[...pubs, hexToBin(node.pkh)],
|
||||
{ provider }
|
||||
)
|
||||
)
|
||||
|
||||
const pool = new Contract(
|
||||
poolArtifact,
|
||||
[
|
||||
...pubs,
|
||||
hexToBin(cfg.treasuryPkh),
|
||||
hexToBin(shares[0].lockingBytecode),
|
||||
hexToBin(shares[1].lockingBytecode),
|
||||
hexToBin(shares[2].lockingBytecode),
|
||||
BigInt(cfg.minConsolidation),
|
||||
BigInt(cfg.splitBlockheight)
|
||||
],
|
||||
{ provider }
|
||||
)
|
||||
|
||||
return { shares, pool }
|
||||
}
|
||||
|
||||
export function feeFromHex (hex, feeRate) {
|
||||
const bytes = hex.length / 2
|
||||
const atRate = BigInt(Math.ceil(bytes * feeRate))
|
||||
const minOne = BigInt(Math.ceil(bytes))
|
||||
return atRate > minOne ? atRate : minOne
|
||||
}
|
||||
|
||||
export function sleep (ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function summarizeUtxo (u) {
|
||||
return {
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
satoshis: u.satoshis.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Height-gated pool split. Fee from companion P2PKH (wallet); auto-carve when needed.
|
||||
*/
|
||||
import {
|
||||
TransactionBuilder,
|
||||
SignatureTemplate,
|
||||
HashType,
|
||||
SignatureAlgorithm
|
||||
} from 'cashscript'
|
||||
import { pkhToCashAddr, toCashScriptUtxos } from './address'
|
||||
import {
|
||||
createProvider,
|
||||
instantiateContracts,
|
||||
feeFromHex,
|
||||
sleep,
|
||||
DEFAULT_FEE_RATE
|
||||
} from './lib'
|
||||
|
||||
const SIGHASH = HashType.SIGHASH_ALL | HashType.SIGHASH_UTXOS
|
||||
const FEE_CARVE_BUFFER = 300n
|
||||
const MAX_REUSE_TIP = 500n
|
||||
const CARVE_CONFIRM_MS = 4000
|
||||
|
||||
function apportion (poolValue) {
|
||||
const treasuryBase = (poolValue * 20n) / 100n
|
||||
const remaining = poolValue - treasuryBase
|
||||
const share = remaining / 3n
|
||||
const remainder = remaining - share * 3n
|
||||
return {
|
||||
treasuryValue: treasuryBase + remainder,
|
||||
share
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSmallFeeUtxo ({ wallet, estimatedFee, dryRun, onStatus }) {
|
||||
const targetCarve = estimatedFee + FEE_CARVE_BUFFER
|
||||
const maxReuse = estimatedFee + MAX_REUSE_TIP
|
||||
|
||||
const load = async () => toCashScriptUtxos(await wallet.getUtxos())
|
||||
let utxos = await load()
|
||||
if (utxos.length === 0) {
|
||||
throw new Error(`Fee wallet has no BCH UTXOs (${wallet.walletInfo.cashAddress})`)
|
||||
}
|
||||
|
||||
const reusable = utxos
|
||||
.filter((u) => u.satoshis >= estimatedFee && u.satoshis <= maxReuse)
|
||||
.sort((a, b) => Number(a.satoshis - b.satoshis))
|
||||
|
||||
if (reusable.length > 0) {
|
||||
return {
|
||||
feeUtxo: reusable[0],
|
||||
carved: false,
|
||||
carveTxid: null,
|
||||
carveAmount: null
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
feeUtxo: {
|
||||
txid: '0'.repeat(64),
|
||||
vout: 0,
|
||||
satoshis: targetCarve,
|
||||
placeholder: true
|
||||
},
|
||||
carved: 'would_carve',
|
||||
carveTxid: null,
|
||||
carveAmount: targetCarve
|
||||
}
|
||||
}
|
||||
|
||||
const amountSat = Number(targetCarve)
|
||||
if (!Number.isSafeInteger(amountSat) || amountSat < 546) {
|
||||
throw new Error(`Invalid carve amount: ${targetCarve}`)
|
||||
}
|
||||
|
||||
if (onStatus) {
|
||||
onStatus(`Carving fee UTXO of ${amountSat} sats…`)
|
||||
}
|
||||
|
||||
const carveTxid = await wallet.send([{
|
||||
address: wallet.walletInfo.cashAddress,
|
||||
amountSat
|
||||
}])
|
||||
|
||||
let carved = null
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
if (onStatus) onStatus(`Waiting for carved fee UTXO (attempt ${attempt + 1}/8)…`)
|
||||
await sleep(CARVE_CONFIRM_MS)
|
||||
await wallet.initialize()
|
||||
utxos = await load()
|
||||
carved = utxos.find(
|
||||
(u) => u.txid === carveTxid && u.satoshis === BigInt(amountSat)
|
||||
)
|
||||
if (carved) break
|
||||
}
|
||||
|
||||
if (!carved) {
|
||||
utxos = await load()
|
||||
carved = utxos.find((u) => u.satoshis === BigInt(amountSat))
|
||||
}
|
||||
|
||||
if (!carved) {
|
||||
throw new Error(
|
||||
`Carved fee UTXO not found after send (txid ${carveTxid}, amount ${amountSat}). Retry shortly.`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
feeUtxo: carved,
|
||||
carved: true,
|
||||
carveTxid,
|
||||
carveAmount: targetCarve
|
||||
}
|
||||
}
|
||||
|
||||
export async function splitPool ({
|
||||
cfg,
|
||||
wallet,
|
||||
feeRate = DEFAULT_FEE_RATE,
|
||||
dryRun = false,
|
||||
onStatus
|
||||
} = {}) {
|
||||
if (!cfg) throw new Error('Deploy config required')
|
||||
if (!wallet) throw new Error('Wallet required for fee payment and broadcast')
|
||||
|
||||
const network = cfg.network || 'mainnet'
|
||||
const provider = createProvider(network)
|
||||
const { pool, shares } = instantiateContracts(cfg, provider)
|
||||
|
||||
const height = await provider.getBlockHeight()
|
||||
if (height < cfg.splitBlockheight) {
|
||||
throw new Error(
|
||||
`splitBlockheight gate not met yet (height ${height} < ${cfg.splitBlockheight})`
|
||||
)
|
||||
}
|
||||
|
||||
const poolUtxos = (await pool.getUtxos()).filter((u) => !u.token)
|
||||
if (poolUtxos.length === 0) {
|
||||
throw new Error('No pool UTXOs found')
|
||||
}
|
||||
if (poolUtxos.length > 1) {
|
||||
throw new Error(
|
||||
`Pool has ${poolUtxos.length} UTXOs — consolidate first (split uses exactly one pool input)`
|
||||
)
|
||||
}
|
||||
|
||||
const poolUtxo = poolUtxos[0]
|
||||
const poolValue = poolUtxo.satoshis
|
||||
const { treasuryValue, share } = apportion(poolValue)
|
||||
const treasuryAddr = pkhToCashAddr(cfg.treasuryPkh, network)
|
||||
|
||||
const probeUtxos = toCashScriptUtxos(await wallet.getUtxos())
|
||||
if (probeUtxos.length === 0) {
|
||||
throw new Error(`Fee wallet has no BCH UTXOs (${wallet.walletInfo.cashAddress})`)
|
||||
}
|
||||
|
||||
const wif = wallet.walletInfo.privateKey
|
||||
const feeTmpl = new SignatureTemplate(wif, SIGHASH, SignatureAlgorithm.ECDSA)
|
||||
|
||||
const build = (feeIn) => new TransactionBuilder({ provider })
|
||||
.addInput(poolUtxo, pool.unlock.split())
|
||||
.addInput(feeIn, feeTmpl.unlockP2PKH())
|
||||
.addOutput({ to: treasuryAddr, amount: treasuryValue })
|
||||
.addOutput({ to: shares[0].address, amount: share })
|
||||
.addOutput({ to: shares[1].address, amount: share })
|
||||
.addOutput({ to: shares[2].address, amount: share })
|
||||
.setLocktime(cfg.splitBlockheight)
|
||||
|
||||
const probeHex = build(probeUtxos[0]).build()
|
||||
let estimatedFee = feeFromHex(probeHex, feeRate)
|
||||
|
||||
const ensured = await ensureSmallFeeUtxo({
|
||||
wallet,
|
||||
estimatedFee,
|
||||
dryRun,
|
||||
onStatus
|
||||
})
|
||||
let feeUtxo = ensured.feeUtxo
|
||||
|
||||
let hex
|
||||
if (feeUtxo.placeholder) {
|
||||
hex = null
|
||||
} else {
|
||||
hex = build(feeUtxo).build()
|
||||
estimatedFee = feeFromHex(hex, feeRate)
|
||||
if (feeUtxo.satoshis < estimatedFee) {
|
||||
const retry = await ensureSmallFeeUtxo({
|
||||
wallet,
|
||||
estimatedFee,
|
||||
dryRun,
|
||||
onStatus
|
||||
})
|
||||
feeUtxo = retry.feeUtxo
|
||||
Object.assign(ensured, retry)
|
||||
if (feeUtxo.placeholder) {
|
||||
hex = null
|
||||
} else {
|
||||
hex = build(feeUtxo).build()
|
||||
estimatedFee = feeFromHex(hex, feeRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (feeUtxo.satoshis < estimatedFee && !feeUtxo.placeholder) {
|
||||
throw new Error(
|
||||
`Fee UTXO ${feeUtxo.satoshis} < required ~${estimatedFee} after carve`
|
||||
)
|
||||
}
|
||||
|
||||
const summary = {
|
||||
poolAddress: pool.address,
|
||||
splitBlockheight: cfg.splitBlockheight,
|
||||
chainHeight: height,
|
||||
poolValue: poolValue.toString(),
|
||||
treasuryAddr,
|
||||
treasuryValue: treasuryValue.toString(),
|
||||
share: share.toString(),
|
||||
shareAddresses: shares.map((s) => s.address),
|
||||
feeAddress: wallet.walletInfo.cashAddress,
|
||||
feeUtxo: {
|
||||
txid: feeUtxo.txid,
|
||||
vout: feeUtxo.vout,
|
||||
satoshis: feeUtxo.satoshis.toString(),
|
||||
placeholder: Boolean(feeUtxo.placeholder)
|
||||
},
|
||||
minerFee: feeUtxo.satoshis.toString(),
|
||||
estimatedFeeAtRate: estimatedFee.toString(),
|
||||
feeRate,
|
||||
carved: ensured.carved,
|
||||
carveTxid: ensured.carveTxid,
|
||||
carveAmount: ensured.carveAmount != null ? ensured.carveAmount.toString() : null,
|
||||
dryRun,
|
||||
hex
|
||||
}
|
||||
|
||||
if (dryRun) return summary
|
||||
|
||||
const txid = await wallet.broadcast({ hex })
|
||||
return { ...summary, txid }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pool status: height gate + pool/share UTXO balances via CashScript Electrum.
|
||||
*/
|
||||
import {
|
||||
createProvider,
|
||||
instantiateContracts,
|
||||
summarizeUtxo
|
||||
} from './lib'
|
||||
|
||||
export async function fetchPoolStatus (cfg) {
|
||||
const network = cfg.network || 'mainnet'
|
||||
const provider = createProvider(network)
|
||||
const { pool, shares } = instantiateContracts(cfg, provider)
|
||||
|
||||
const height = await provider.getBlockHeight()
|
||||
const poolUtxos = (await pool.getUtxos()).filter((u) => !u.token)
|
||||
const poolTotal = poolUtxos.reduce((s, u) => s + u.satoshis, 0n)
|
||||
|
||||
const shareStatuses = []
|
||||
for (let i = 0; i < shares.length; i++) {
|
||||
const utxos = (await shares[i].getUtxos()).filter((u) => !u.token)
|
||||
const total = utxos.reduce((s, u) => s + u.satoshis, 0n)
|
||||
shareStatuses.push({
|
||||
index: i,
|
||||
name: cfg.nodes[i].name || `node${i}`,
|
||||
address: shares[i].address,
|
||||
utxoCount: utxos.length,
|
||||
totalSatoshis: total.toString(),
|
||||
utxos: utxos.map(summarizeUtxo)
|
||||
})
|
||||
}
|
||||
|
||||
const gateMet = height >= cfg.splitBlockheight
|
||||
const needsConsolidate = poolUtxos.length >= 2
|
||||
const canSplit = gateMet && poolUtxos.length === 1
|
||||
|
||||
return {
|
||||
network,
|
||||
height,
|
||||
splitBlockheight: cfg.splitBlockheight,
|
||||
gateMet,
|
||||
minConsolidation: cfg.minConsolidation,
|
||||
poolAddress: pool.address,
|
||||
poolUtxoCount: poolUtxos.length,
|
||||
poolTotalSatoshis: poolTotal.toString(),
|
||||
poolUtxos: poolUtxos.map(summarizeUtxo),
|
||||
shares: shareStatuses,
|
||||
needsConsolidate,
|
||||
canSplit,
|
||||
hint: !gateMet
|
||||
? `Wait until block ${cfg.splitBlockheight} (current ${height}) before split`
|
||||
: needsConsolidate
|
||||
? 'Consolidate pool UTXOs before split'
|
||||
: poolUtxos.length === 0
|
||||
? 'Pool has no UTXOs'
|
||||
: canSplit
|
||||
? 'Ready to split'
|
||||
: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
A utility library for holding functions that are commonly used by many different
|
||||
areas of the app.
|
||||
*/
|
||||
|
||||
class AppUtil {
|
||||
// Returns a promise that resolves 'ms' milliseconds.
|
||||
sleep (ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Copy a text to clipboard
|
||||
async copyToClipboard (text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err)
|
||||
// document.body.removeChild(textarea)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Read text from clipboard
|
||||
async readFromClipboard () {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
|
||||
return text
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err)
|
||||
// document.body.removeChild(textarea)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AppUtil
|
||||
Reference in New Issue
Block a user