mirror of
https://github.com/Coldcard/push-tx.git
synced 2026-04-26 17:04:01 +00:00
add code
This commit is contained in:
3
cc_website/.gitignore
vendored
Normal file
3
cc_website/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
build/index.html
|
||||
16
cc_website/Makefile
Normal file
16
cc_website/Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install
|
||||
|
||||
.PHONY: build
|
||||
build: install
|
||||
npm run build && PUSHTX_SINGLE_FILE=1 npm run build
|
||||
|
||||
# hash and sign all important files - both the source and built versions
|
||||
.PHONY: sign
|
||||
sign:
|
||||
find . -type f \
|
||||
\( -iname '*.js' -o -iname '*.ts' -o -iname '*.html' -o -iname '*.css' -o -iname '*.json' \) \
|
||||
! -path './node_modules/*' \
|
||||
-exec shasum -a 256 {} \; > SHA256SUMS && \
|
||||
gpg --detach-sign --armor --digest-algo SHA256 --yes SHA256SUMS
|
||||
46
cc_website/README.md
Normal file
46
cc_website/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# COLDCARD Website Implementation
|
||||
|
||||
This is the code for the NFC Push TX implementation at <https://coldcard.com/pushtx>.
|
||||
|
||||
## Overview
|
||||
|
||||
We use the APIs of mempool.space and blockstream.info to broadcast the transaction
|
||||
and for retrieving additional transaction details afterwards.
|
||||
|
||||
In an edge case where the transaction is already confirmed (e.g. same Push TX URL
|
||||
used twice), the APIs respond with a 400 error. If that happens, we use
|
||||
[bitcoinjs-lib](https://github.com/bitcoinjs/bitcoinjs-lib) to compute the TXID
|
||||
and try to fetch the details anyway.
|
||||
|
||||
## How to Build
|
||||
|
||||
To build, you need Node.js and NPM installed. There's a `Makefile` target for
|
||||
installing dependencies and running the build:
|
||||
|
||||
```
|
||||
make build
|
||||
```
|
||||
|
||||
This builds two versions:
|
||||
|
||||
- Separate JS and CSS files that power <https://coldcard.com/pushtx> - saved to `build/`
|
||||
- A self-contained HTML where all JS and CSS is inlined, which is useful for easy
|
||||
self-hosting - saved to `build-single-file/`.
|
||||
|
||||
## Don't Trust, Verify
|
||||
|
||||
The `SHA256SUMS` file includes hashes of all important files in this repo and is
|
||||
signed with the PGP key `3F4D 2119 6E14 FD39 3835 492D F960 23C5 8E14 72C9` in
|
||||
`SHA256SUMS.asc` (detached signature).
|
||||
|
||||
To check the signature:
|
||||
|
||||
```
|
||||
gpg --verify SHA256SUMS.asc
|
||||
```
|
||||
|
||||
To check the hashes match:
|
||||
|
||||
```
|
||||
shasum -a 256 -c SHA256SUMS
|
||||
```
|
||||
14
cc_website/SHA256SUMS
Normal file
14
cc_website/SHA256SUMS
Normal file
@@ -0,0 +1,14 @@
|
||||
791562fe4df2457484ffeb21774990451c59ca1818ccf71003334fe2576dc9a0 ./index.html
|
||||
5ef88a316223b466c86c5e87991c982591b2b354d365a13d7451201c651e7ced ./build-single-file/index.html
|
||||
7f8469776f50d7d766cbe3e438e290baf2d91815a3d8233f248b6e412bc8c94e ./package-lock.json
|
||||
d035d1b528f544331f8c697da0d407427f409b4fbb6f86232ec1445c16e85a7e ./package.json
|
||||
68df121854e628c7d84ee41b7d86875626cc342aa0c8b0f939af322eb4ba1723 ./tsconfig.json
|
||||
648d982c9f40db8a813a7fbc9c971b63f85a84a07a307474f71f85653afbdfde ./build/index.html
|
||||
2462f20149c235e936de6f9420cc0a4630aeb9c26f5dc6c5b906c11edbfc720c ./build/pushtx.js
|
||||
31ebc58b766680c1000cabd7b7da7eeb943aff58ebc805beba608e15ffc7a3f6 ./build/pushtx.css
|
||||
2ede67555dd622a2be580b3e15fdde6463413f63e79913f54faa75d819a4cc01 ./vite.config.ts
|
||||
91d383919e958aa174cb525b7b1c415e50e125b32349673235ac850d88ea4a0b ./src/main.ts
|
||||
b41fd5552c271453ce7ee1c661e72495705e5da0e005828b17f4ad057ba91e87 ./src/types.ts
|
||||
65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5 ./src/vite-env.d.ts
|
||||
4e6dd4e5d1689110a3645a7ee5f82fac8ec32d4aad8c00d906be60f64ebca678 ./src/style.css
|
||||
54cc1fff898ecd3f437d82947889bbcab5f861904e61c9c1eea9f792a3f32582 ./src/config.ts
|
||||
16
cc_website/SHA256SUMS.asc
Normal file
16
cc_website/SHA256SUMS.asc
Normal file
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAABCAAdFiEEP00hGW4U/Tk4NUkt+WAjxY4UcskFAmZsZVgACgkQ+WAjxY4U
|
||||
csmR2w//QkLrh012rneiKhThO1xSgkeeFv05lKDmKDZHxB6m+0YATqmkgZx0dnwo
|
||||
X4rva4QBgqjr/H5sZI364pNi4B+pgzLV1PjEMvsvEcRMi8mQL9Y+GyTGUE0GndJW
|
||||
oWQ4aYcVsV0Qz0mcEGNsupe7NOFKZQhTooSULv3gOtfkwbAejlFJRBiddYQzc9aX
|
||||
GZYM9S3dVV5DK0zeNf8Z5UofEyFY6vJ8nP6bmVzGgzmI5gzjFKPNH8aLpyQvkdQI
|
||||
0VNZ1P4HZQH9iw4z7wIHCqmCdPdERx/3roQmQ++v83ieozCNelamkRdGWnT4sV9T
|
||||
PpigNofqolgxauf506fdfewPqQQ/Ir1EQGAE7G+E/lCxFe+P6lipPkwdJGr6v/g6
|
||||
Co1WhEij0r++COF4oj9TPQCDtsEhCP2vMbomDL+pnOIGPuNtZvKKLJiYa93+o5QR
|
||||
RWo5m1ZP9yU+xs8v+5Gp/xQAZQD7AYGllCkNG89L+bkXvuFbHBh20hWXjPisGMcb
|
||||
MyC5iN7L+x3lu/t2iq+XvmNo9K504WWMzmzVbI2FWxTabbEA0IwQSZhbZDeGHAYP
|
||||
tBfLnnOzlcYXS3RTJXPE5QrHop6UIyitrzVH/TkJABJOmWUKhm/to43lBA2+NNEg
|
||||
nOUrhJl0BnYHmPgTkjVqvuPzxe+2dgquYfbwJ/W+sPSsosSpySQ=
|
||||
=wiI5
|
||||
-----END PGP SIGNATURE-----
|
||||
140
cc_website/build-single-file/index.html
Normal file
140
cc_website/build-single-file/index.html
Normal file
File diff suppressed because one or more lines are too long
1
cc_website/build/pushtx.css
Normal file
1
cc_website/build/pushtx.css
Normal file
@@ -0,0 +1 @@
|
||||
.pushtx *{margin:0;padding:0;box-sizing:border-box;color:inherit!important}.pushtx ul{list-style-position:inside}.pushtx .monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.pushtx-spin{animation:spin 1s linear infinite}.pushtx-message{display:flex;gap:1rem;padding:1rem;border:1px solid transparent;border-radius:.25rem;width:100%}.pushtx-message>:first-child{flex-shrink:0}.pushtx-message>:last-child{overflow:hidden;overflow-wrap:break-word;display:flex;flex-direction:column;gap:.5rem}.pushtx-message--success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.pushtx-message--info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.pushtx-message--progress{color:#004085;background-color:#cce5ff;border-color:#b8daff}.pushtx-message--error{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.pushtx-message .txid{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.875em;font-weight:700}.pushtx-details{display:flex;flex-direction:column;margin-block:2rem;padding:.5rem;border-radius:.25rem;overflow:hidden;gap:.5rem;border:1px solid #e2e8f0}.pushtx-details table{border-collapse:collapse;width:100%}.pushtx-details th{text-align:left}.pushtx-details :is(th,td){border-width:0px;padding-block:.125rem}.pushtx-details :is(td:first-child,th:first-child){width:75%;max-width:0px}.pushtx-details :is(td:last-child,th:last-child){text-align:right}.pushtx-details :is(.address,.value){font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.pushtx-details .address{display:flex;padding-right:1em}.pushtx-details .address :first-child{overflow:hidden;text-overflow:ellipsis}.pushtx-details .fee{text-align:right}
|
||||
111
cc_website/build/pushtx.js
Normal file
111
cc_website/build/pushtx.js
Normal file
File diff suppressed because one or more lines are too long
27
cc_website/index.html
Normal file
27
cc_website/index.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>COLDCARD NFC Transaction Broadcast</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="pushtx" style="
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
max-width: 1024px;
|
||||
margin: 5vh auto;
|
||||
">
|
||||
|
||||
<h1 style="margin-bottom: 2rem;">COLDCARD NFC Transaction Broadcast</h1>
|
||||
|
||||
<div>
|
||||
<div class="pushtx-message-area"></div>
|
||||
<div class="pushtx-details-area"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
3
cc_website/index.md
Normal file
3
cc_website/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# COLDCARD NFC Push TX
|
||||
|
||||
🔜
|
||||
2465
cc_website/package-lock.json
generated
Normal file
2465
cc_website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
cc_website/package.json
Normal file
19
cc_website/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "coldcard-pushtx",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.2",
|
||||
"bitcoinjs-lib": "6.1.6",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "5.2.13",
|
||||
"vite-plugin-node-polyfills": "0.22.0",
|
||||
"vite-plugin-singlefile": "2.0.1"
|
||||
}
|
||||
}
|
||||
53
cc_website/src/config.ts
Normal file
53
cc_website/src/config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const TX_API_URLS = {
|
||||
// POST to these when pushing a TX (body is transaction HEX)
|
||||
// GET .../{txid} to get TX details
|
||||
BTC: ['https://mempool.space/api/tx', 'https://blockstream.info/api/tx'],
|
||||
XTN: ['https://mempool.space/testnet/api/tx', 'https://blockstream.info/testnet/api/tx'],
|
||||
} as const;
|
||||
|
||||
export const BLOCK_EXPLORER_CONFIG = {
|
||||
// Name and URL prefix of block explorers that we link to after TX is pushed
|
||||
// TXID will be appended to the URL
|
||||
BTC: [
|
||||
['mempool.space', 'https://mempool.space/tx/'],
|
||||
['blockstream.info', 'https://blockstream.info/tx/'],
|
||||
['btcscan.org', 'https://btcscan.org/tx/'],
|
||||
['btc.com', 'https://explorer.btc.com/btc/transaction/'],
|
||||
],
|
||||
XTN: [
|
||||
['mempool.space', 'https://mempool.space/testnet/tx/'],
|
||||
['blockstream.info', 'https://blockstream.info/testnet/tx/'],
|
||||
['blockcypher.com', 'https://live.blockcypher.com/btc-testnet/tx/'],
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const MESSAGE_ICONS = {
|
||||
// SVGs for icons/spinner on the message box
|
||||
// - taken from https://icons.getbootstrap.com
|
||||
// - but inlining because easier to bundle into a single file this way
|
||||
|
||||
success: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="m10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05"/>
|
||||
</svg>`,
|
||||
|
||||
error: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
|
||||
</svg>`,
|
||||
|
||||
info: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-info-circle" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
|
||||
</svg>`,
|
||||
|
||||
// note: spinning animation is defined in CSS file
|
||||
progress: `
|
||||
<svg class="pushtx-spin" width="32" height="32" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle style="opacity: 0.25;" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>`,
|
||||
} as const;
|
||||
398
cc_website/src/main.ts
Normal file
398
cc_website/src/main.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { Transaction } from 'bitcoinjs-lib';
|
||||
import { Buffer } from 'buffer';
|
||||
import { BLOCK_EXPLORER_CONFIG, MESSAGE_ICONS, TX_API_URLS } from './config';
|
||||
import './style.css';
|
||||
import { MempoolTxData, Network } from './types';
|
||||
|
||||
/**
|
||||
* Helper for awaiting either the return value or error of a promise.
|
||||
* Very useful for avoiding endless try/catch nesting and scoping hell.
|
||||
*/
|
||||
async function collect<T>(promise: Promise<T>): Promise<[null, T] | [Error, null]> {
|
||||
try {
|
||||
return [null, await promise];
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return [err, null];
|
||||
} else if (typeof err === 'string') {
|
||||
return [new Error(err), null];
|
||||
}
|
||||
|
||||
return [new Error(), null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a base64url string to a Uint8Array
|
||||
*/
|
||||
function b64UrlToBytes(base64Url: string): Uint8Array {
|
||||
const base64 = base64Url
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(base64Url.length + ((4 - (base64Url.length % 4)) % 4), '=');
|
||||
const binaryString = atob(base64);
|
||||
return new Uint8Array([...binaryString].map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error for fetchSafe below.
|
||||
*/
|
||||
class FetchStatusError extends Error {
|
||||
url: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: string | null;
|
||||
|
||||
constructor(url: string, status: number, statusText: string, body: string | null) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.name = 'FetchStatusError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fetch that throws an error if the response is not 2xx.
|
||||
*/
|
||||
const fetchSafe: typeof fetch = async (input, init) => {
|
||||
const resp = await fetch(input, init);
|
||||
|
||||
if (!resp.ok) {
|
||||
const [_, body] = await collect(resp.text());
|
||||
throw new FetchStatusError(input.toString(), resp.status, resp.statusText, body);
|
||||
}
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a transaction to some providers:
|
||||
* - Currently mempool.space and blockstream.info, perhaps more in the future
|
||||
* - Supporting mainnet and testnet
|
||||
*/
|
||||
async function pushTx(tx: Transaction, network: Network): Promise<string> {
|
||||
if (network !== 'BTC' && network !== 'XTN') {
|
||||
throw new Error('Unsupported network: ' + network);
|
||||
}
|
||||
|
||||
const urls = TX_API_URLS[network];
|
||||
|
||||
const promises = urls.map((url) => fetchSafe(url, { method: 'POST', body: tx.toHex() }));
|
||||
|
||||
const [err, txid] = await collect(Promise.any(promises).then((res) => res.text()));
|
||||
|
||||
if (txid) {
|
||||
return txid;
|
||||
}
|
||||
|
||||
if (err instanceof AggregateError) {
|
||||
const fetchStatusErrors = err.errors.filter(
|
||||
(e): e is FetchStatusError => e instanceof FetchStatusError
|
||||
);
|
||||
|
||||
if (fetchStatusErrors.length > 0) {
|
||||
let errMsg = `
|
||||
<p>The transaction was rejected by all providers:</p>
|
||||
|
||||
<ul>
|
||||
`;
|
||||
|
||||
fetchStatusErrors.forEach((e) => {
|
||||
errMsg += `<li>${e.url}: ${e.body}</li>`;
|
||||
});
|
||||
|
||||
errMsg += `</ul>`;
|
||||
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`<p>Could not connect to any push servers. Make sure you are connected to the Internet and try again.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the transaction data and network from the URL fragment
|
||||
* @param fragment - The URL fragment, e.g. #t=base64tx&c=base64checksum&n=XTN
|
||||
*/
|
||||
async function parseFragment(fragment: string) {
|
||||
if (fragment[0] === '#') {
|
||||
fragment = fragment.slice(1);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(fragment);
|
||||
|
||||
const t = params.get('t');
|
||||
const c = params.get('c');
|
||||
const n = params.get('n') || 'BTC';
|
||||
|
||||
if (!t) {
|
||||
throw new Error('Invalid URL - missing transaction.');
|
||||
}
|
||||
|
||||
if (!c || c.length !== 11) {
|
||||
throw new Error('Invalid URL - missing or incomplete checksum. The URL is probably truncated');
|
||||
}
|
||||
|
||||
let network: Network;
|
||||
|
||||
if (n === 'BTC' || n === 'XTN') {
|
||||
network = n;
|
||||
} else if (n === 'XRT') {
|
||||
throw new Error('Regtest transactions are not supported.');
|
||||
} else {
|
||||
throw new Error(`Invalid URL. The network "${n}" is not recognized.`);
|
||||
}
|
||||
|
||||
let txBytes: Uint8Array;
|
||||
let checkBytes: Uint8Array;
|
||||
|
||||
try {
|
||||
txBytes = b64UrlToBytes(t);
|
||||
checkBytes = b64UrlToBytes(c);
|
||||
} catch (err) {
|
||||
throw new Error('Invalid URL encoding. The URL is probably corrupted.');
|
||||
}
|
||||
|
||||
const txHash = new Uint8Array(await crypto.subtle.digest('SHA-256', txBytes));
|
||||
|
||||
if (!checkBytes.every((byte, i) => byte === txHash[i + 24])) {
|
||||
throw new Error('Checksum mismatch in URL. Some bytes corrupted in transit. Try again.');
|
||||
}
|
||||
|
||||
const tx = Transaction.fromBuffer(Buffer.from(txBytes));
|
||||
|
||||
return {
|
||||
tx,
|
||||
network,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the transaction details from mempool/blockstream to render useful information
|
||||
* that we can't (easily) get from the transaction itself, e.g. the input and output addresses
|
||||
* - Cancel other requests once the first one comes back OK.
|
||||
*/
|
||||
async function fetchTxData(txid: string, network: Network): Promise<MempoolTxData> {
|
||||
if (network !== 'BTC' && network !== 'XTN') {
|
||||
throw new Error('Unsupported network: ' + network);
|
||||
}
|
||||
|
||||
const urls = TX_API_URLS[network];
|
||||
|
||||
const promises = urls.map((url) => fetchSafe(url + '/' + txid).then((resp) => resp.json()));
|
||||
|
||||
return await Promise.any(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML for the transaction details that can be rendered on the page.
|
||||
*/
|
||||
function renderTxDetails(txData: MempoolTxData): string {
|
||||
const inputRows = txData.vin
|
||||
.map((input) => {
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="address">
|
||||
<span>${input.prevout.scriptpubkey_address.slice(0, -8)}</span>
|
||||
<span>${input.prevout.scriptpubkey_address.slice(-8)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="value">${(input.prevout.value / 1e8).toFixed(8)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const outputRows = txData.vout
|
||||
.map((output) => {
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="address">
|
||||
<span>${output.scriptpubkey_address.slice(0, -8)}</span>
|
||||
<span>${output.scriptpubkey_address.slice(-8)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="value">${(output.value / 1e8).toFixed(8)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="pushtx-details">
|
||||
<div class="inputs">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Inputs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${inputRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="outputs">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Outputs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
${outputRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="fee">
|
||||
<strong>Fee:</strong> <span class="value">${(txData.fee / 1e8).toFixed(8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML for a message box that can be rendered on the page.
|
||||
* @param type - The type of message, e.g. 'success', 'error', 'info'
|
||||
* @param message - The message to display - can be HTML
|
||||
*/
|
||||
function renderMessage(type: 'success' | 'error' | 'info' | 'progress', message: string): string {
|
||||
const icon = MESSAGE_ICONS[type];
|
||||
|
||||
return `
|
||||
<div class="pushtx-message pushtx-message--${type}" role="alert">
|
||||
${icon}
|
||||
<div>${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For testing - convert an exisiting HEX transaction to a URL fragment
|
||||
*/
|
||||
async function txToUrlFragment(hex: string, network: Network) {
|
||||
const txBytes = new Uint8Array(hex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
||||
const txHash = new Uint8Array(await crypto.subtle.digest('SHA-256', txBytes));
|
||||
|
||||
const tx = btoa(String.fromCharCode(...txBytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
const check = btoa(String.fromCharCode(...txHash.slice(24)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
let fragment = `#t=${tx}&c=${check}`;
|
||||
|
||||
if (network) {
|
||||
fragment += `&n=${network}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const messageArea = document.querySelector<HTMLDivElement>('.pushtx-message-area');
|
||||
const detailsArea = document.querySelector<HTMLDivElement>('.pushtx-details-area');
|
||||
|
||||
if (!messageArea || !detailsArea) {
|
||||
throw new Error('Need message and details areas in HTML.');
|
||||
}
|
||||
|
||||
messageArea.innerHTML = '';
|
||||
detailsArea.innerHTML = '';
|
||||
|
||||
if (!window.location.hash) {
|
||||
const msg = `
|
||||
<p><strong>Did you get here by accident?</strong></p>
|
||||
<p>
|
||||
This page is meant to be loaded together with transaction data using the
|
||||
<strong>COLDCARD NFC Push TX feature</strong>. The complete URL should look something like this (but longer):
|
||||
</p>
|
||||
|
||||
<p><code>https://coldcard.com/pushtx#t=AgAAAAMNCxXtp2GVYVhkRXHLMmdZFs4p3kbFK ⋯ ABf&c=uiSVRda-1tw</code></p>
|
||||
`;
|
||||
|
||||
messageArea.innerHTML = renderMessage('info', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
messageArea.innerHTML = renderMessage('progress', 'Sending transaction, please wait...');
|
||||
|
||||
const [parseErr, parseResult] = await collect(parseFragment(window.location.hash));
|
||||
|
||||
if (parseErr) {
|
||||
messageArea.innerHTML = renderMessage('error', parseErr.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { tx, network } = parseResult;
|
||||
|
||||
const txid = tx.getId();
|
||||
|
||||
const [pushErr, pushResult] = await collect(pushTx(tx, network));
|
||||
|
||||
// XXX - sometimes we get something like `{"code":-25,"message":"bad-txns-inputs-missingorspent"}`
|
||||
// but the transaction is actually confirmed, so:
|
||||
// - try to fetch the TX details by ID, even if pushing failed
|
||||
// - if it's found, show a green message and say it's pending or confirmed
|
||||
const [mempoolErr, mempoolTxData] = await collect(fetchTxData(txid, network));
|
||||
|
||||
if (pushResult || mempoolTxData) {
|
||||
// push was successful and/or we got the TX details back
|
||||
const msg = mempoolTxData?.status.confirmed
|
||||
? 'This transaction has already been confirmed.'
|
||||
: 'The transaction has been sent and is waiting to be confirmed.';
|
||||
|
||||
const listHtml = BLOCK_EXPLORER_CONFIG[network]
|
||||
.map(
|
||||
([name, url]) =>
|
||||
`<li><a href="${url}${txid}" target="_blank" rel="noopener">${name}</a></li>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
messageArea.innerHTML = renderMessage(
|
||||
'success',
|
||||
`<p>${msg} Transaction ID:</p>
|
||||
|
||||
<p class="txid">${txid}</p>
|
||||
|
||||
<p>Verify on a block explorer:</p>
|
||||
|
||||
<ul>${listHtml}</ul>
|
||||
`
|
||||
);
|
||||
|
||||
if (mempoolTxData) {
|
||||
detailsArea.innerHTML = renderTxDetails(mempoolTxData);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pushErr && mempoolErr) {
|
||||
// pushing failed and we also couldn't fetch the TX details
|
||||
messageArea.innerHTML = renderMessage('error', pushErr.message);
|
||||
} else if (mempoolErr) {
|
||||
messageArea.innerHTML = renderMessage('error', mempoolErr.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
window.addEventListener('hashchange', run);
|
||||
130
cc_website/src/style.css
Normal file
130
cc_website/src/style.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.pushtx * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.pushtx ul {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.pushtx .monospace {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pushtx-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.pushtx-message {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pushtx-message > :first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pushtx-message > :last-child {
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pushtx-message--success {
|
||||
color: #155724;
|
||||
background-color: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.pushtx-message--info {
|
||||
color: #0c5460;
|
||||
background-color: #d1ecf1;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
.pushtx-message--progress {
|
||||
color: #004085;
|
||||
background-color: #cce5ff;
|
||||
border-color: #b8daff;
|
||||
}
|
||||
|
||||
.pushtx-message--error {
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.pushtx-message .txid {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pushtx-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-block: 2rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.pushtx-details table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pushtx-details th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pushtx-details :is(th, td) {
|
||||
border-width: 0px;
|
||||
padding-block: 0.125rem;
|
||||
}
|
||||
|
||||
.pushtx-details :is(td:first-child, th:first-child) {
|
||||
width: 75%;
|
||||
max-width: 0px;
|
||||
}
|
||||
|
||||
.pushtx-details :is(td:last-child, th:last-child) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pushtx-details :is(.address, .value) {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.pushtx-details .address {
|
||||
display: flex;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.pushtx-details .address :first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pushtx-details .fee {
|
||||
text-align: right;
|
||||
}
|
||||
25
cc_website/src/types.ts
Normal file
25
cc_website/src/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Network = 'BTC' | 'XTN' | 'XRT';
|
||||
|
||||
interface MempoolConfirmedStatus {
|
||||
confirmed: true;
|
||||
block_height: number;
|
||||
}
|
||||
|
||||
interface MempoolUnconfirmedStatus {
|
||||
confirmed: false;
|
||||
}
|
||||
|
||||
export interface MempoolTxData {
|
||||
vin: {
|
||||
prevout: {
|
||||
scriptpubkey_address: string;
|
||||
value: number;
|
||||
};
|
||||
}[];
|
||||
vout: {
|
||||
scriptpubkey_address: string;
|
||||
value: number;
|
||||
}[];
|
||||
fee: number;
|
||||
status: MempoolConfirmedStatus | MempoolUnconfirmedStatus;
|
||||
}
|
||||
1
cc_website/src/vite-env.d.ts
vendored
Normal file
1
cc_website/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
23
cc_website/tsconfig.json
Normal file
23
cc_website/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
58
cc_website/vite.config.ts
Normal file
58
cc_website/vite.config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, type UserConfig } from 'vite';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
|
||||
const plugins = [
|
||||
nodePolyfills({
|
||||
// needed for bitcoinjs-lib
|
||||
include: ['buffer'],
|
||||
globals: {
|
||||
Buffer: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// "normal" configuration - building for coldcard.com/pushtx
|
||||
// - creates a one JS and one CSS file
|
||||
const websiteConfig: UserConfig = {
|
||||
plugins,
|
||||
build: {
|
||||
outDir: 'build',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
if (chunkInfo.name !== 'index') {
|
||||
throw new Error(
|
||||
`Build failed. Expected a single JS chunk "index". Saw: "${chunkInfo.name}".`
|
||||
);
|
||||
}
|
||||
return 'pushtx.js';
|
||||
},
|
||||
assetFileNames: (chunkInfo) => {
|
||||
if (chunkInfo.name !== 'index.css') {
|
||||
throw new Error(
|
||||
`Build failed. Expected a single CSS chunk "index.css". Saw: "${chunkInfo.name}".`
|
||||
);
|
||||
}
|
||||
return 'pushtx.css';
|
||||
},
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
throw new Error(`Build failed. Not expecting any chunk files. Saw: "${chunkInfo.name}".`);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// single file config - builds a single HTML file with everything inlined
|
||||
// can be downloaded from coldcard.com/pushtx to self-host
|
||||
const singleFileConfig: UserConfig = {
|
||||
plugins: [...plugins, viteSingleFile()],
|
||||
build: {
|
||||
outDir: 'build-single-file',
|
||||
},
|
||||
};
|
||||
|
||||
const finalConfig = process.env.PUSHTX_SINGLE_FILE ? singleFileConfig : websiteConfig;
|
||||
|
||||
export default defineConfig(finalConfig);
|
||||
Reference in New Issue
Block a user