mirror of
https://github.com/Coldcard/push-tx.git
synced 2026-04-26 17:04:01 +00:00
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