This commit is contained in:
siim-m
2024-06-19 13:19:36 +03:00
committed by doc-hex
parent eddfa75c9a
commit b0e52cf356
19 changed files with 0 additions and 0 deletions

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;

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);

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;
}

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-implementation/src/vite-env.d.ts vendored Normal file
View File

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