This commit is contained in:
siim-m
2024-06-14 18:35:25 +03:00
parent 5a1adc441f
commit f873e07db4
19 changed files with 3549 additions and 0 deletions

3
cc_website/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
node_modules
build/index.html

16
cc_website/Makefile Normal file
View 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
View 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
View 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
View 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-----

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

27
cc_website/index.html Normal file
View 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
View File

@@ -0,0 +1,3 @@
# COLDCARD NFC Push TX
🔜

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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

23
cc_website/tsconfig.json Normal file
View 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
View 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);