322 lines
9.0 KiB
TypeScript
322 lines
9.0 KiB
TypeScript
import { ProcessState } from '../../../pkg/sdk_client';
|
|
import Services from '../../services/service';
|
|
|
|
interface State {
|
|
file: File | null;
|
|
fileHash: string | null;
|
|
certificate: ProcessState | null;
|
|
commitmentHashes: string[];
|
|
}
|
|
|
|
export interface Vin {
|
|
txid: string; // The txid of the previous transaction (being spent)
|
|
vout: number; // The output index in the previous tx
|
|
prevout: {
|
|
scriptpubkey: string;
|
|
scriptpubkey_asm: string;
|
|
scriptpubkey_type: string;
|
|
scriptpubkey_address: string;
|
|
value: number;
|
|
};
|
|
scriptsig: string;
|
|
scriptsig_asm: string;
|
|
witness: string[];
|
|
is_coinbase: boolean;
|
|
sequence: number;
|
|
}
|
|
|
|
export interface TransactionInfo {
|
|
txid: string;
|
|
version: number;
|
|
locktime: number;
|
|
vin: Vin[];
|
|
vout: any[];
|
|
size: number;
|
|
weight: number;
|
|
fee: number;
|
|
status: {
|
|
confirmed: boolean;
|
|
block_height: number;
|
|
block_hash: string;
|
|
block_time: number;
|
|
};
|
|
}
|
|
|
|
export function getDocumentValidation(container: HTMLElement) {
|
|
const state: State = {
|
|
file: null,
|
|
fileHash: null,
|
|
certificate: null,
|
|
commitmentHashes: []
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
container.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
gap: 2rem;
|
|
`;
|
|
|
|
function createDropButton(
|
|
label: string,
|
|
onDrop: (file: File, updateVisuals: (file: File) => void) => void,
|
|
accept: string = '*/*'
|
|
): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.cssText = `
|
|
width: 200px;
|
|
height: 100px;
|
|
border: 2px dashed #888;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
background: #f8f8f8;
|
|
text-align: center;
|
|
padding: 0.5rem;
|
|
box-sizing: border-box;
|
|
`;
|
|
|
|
const title = document.createElement('div');
|
|
title.textContent = label;
|
|
|
|
const filename = document.createElement('div');
|
|
filename.style.cssText = `
|
|
font-size: 0.85rem;
|
|
margin-top: 0.5rem;
|
|
color: #444;
|
|
word-break: break-word;
|
|
text-align: center;
|
|
`;
|
|
|
|
wrapper.appendChild(title);
|
|
wrapper.appendChild(filename);
|
|
|
|
const updateVisuals = (file: File) => {
|
|
wrapper.style.borderColor = 'green';
|
|
wrapper.style.background = '#e6ffed';
|
|
filename.textContent = file.name;
|
|
};
|
|
|
|
// === Hidden file input ===
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = accept;
|
|
fileInput.style.display = 'none';
|
|
document.body.appendChild(fileInput);
|
|
|
|
fileInput.onchange = () => {
|
|
const file = fileInput.files?.[0];
|
|
if (file) {
|
|
onDrop(file, updateVisuals);
|
|
fileInput.value = ''; // reset so same file can be re-selected
|
|
}
|
|
};
|
|
|
|
// === Handle drag-and-drop ===
|
|
wrapper.ondragover = e => {
|
|
e.preventDefault();
|
|
wrapper.style.background = '#e0e0e0';
|
|
};
|
|
|
|
wrapper.ondragleave = () => {
|
|
wrapper.style.background = '#f8f8f8';
|
|
};
|
|
|
|
wrapper.ondrop = e => {
|
|
e.preventDefault();
|
|
wrapper.style.background = '#f8f8f8';
|
|
|
|
const file = e.dataTransfer?.files?.[0];
|
|
if (file) {
|
|
onDrop(file, updateVisuals);
|
|
}
|
|
};
|
|
|
|
// === Handle click to open file manager ===
|
|
wrapper.onclick = () => {
|
|
fileInput.click();
|
|
};
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
const fileDropButton = createDropButton('Drop file', async (file, updateVisuals) => {
|
|
try {
|
|
state.file = file;
|
|
updateVisuals(file);
|
|
console.log('Loaded file:', state.file);
|
|
checkReady();
|
|
} catch (err) {
|
|
alert('Failed to drop the file.');
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
const certDropButton = createDropButton('Drop certificate', async (file, updateVisuals) => {
|
|
try {
|
|
const text = await file.text();
|
|
const json = JSON.parse(text);
|
|
if (
|
|
typeof json === 'object' &&
|
|
json !== null &&
|
|
typeof json.pcd_commitment === 'object' &&
|
|
typeof json.state_id === 'string'
|
|
) {
|
|
state.certificate = json as ProcessState;
|
|
|
|
state.commitmentHashes = Object.values(json.pcd_commitment).map((h: string) =>
|
|
h.toLowerCase()
|
|
);
|
|
|
|
updateVisuals(file);
|
|
console.log('Loaded certificate, extracted hashes:', state.commitmentHashes);
|
|
checkReady();
|
|
} else {
|
|
alert('Invalid certificate structure.');
|
|
}
|
|
} catch (err) {
|
|
alert('Failed to parse certificate JSON.');
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
const buttonRow = document.createElement('div');
|
|
buttonRow.style.display = 'flex';
|
|
buttonRow.style.gap = '2rem';
|
|
buttonRow.appendChild(fileDropButton);
|
|
buttonRow.appendChild(certDropButton);
|
|
|
|
container.appendChild(buttonRow);
|
|
|
|
async function checkReady() {
|
|
if (state.file && state.certificate && state.commitmentHashes.length > 0) {
|
|
// We take the commited_in and all pcd_commitment keys to reconstruct all the possible hash
|
|
const fileBlob = {
|
|
type: state.file.type,
|
|
data: new Uint8Array(await state.file.arrayBuffer())
|
|
};
|
|
const service = await Services.getInstance();
|
|
const commitedIn = state.certificate.commited_in;
|
|
if (!commitedIn) return;
|
|
const [prevTxid, prevTxVout] = commitedIn.split(':');
|
|
const processId = state.certificate.process_id;
|
|
const stateId = state.certificate.state_id;
|
|
const process = await service.getProcess(processId);
|
|
if (!process) return;
|
|
|
|
// Get the transaction that comes right after the commited_in
|
|
const nextState = service.getNextStateAfterId(process, stateId);
|
|
|
|
if (!nextState) {
|
|
alert(`❌ Validation failed: No next state, is the state you're trying to validate commited?`);
|
|
return;
|
|
}
|
|
|
|
const [outspentTxId, _] = nextState.commited_in.split(':');
|
|
console.log(outspentTxId);
|
|
|
|
// Check that the commitment transaction exists, and that it commits to the state id
|
|
|
|
const txInfo = await fetchTransaction(outspentTxId);
|
|
if (!txInfo) {
|
|
console.error(`Validation error: Can't fetch new state commitment transaction`);
|
|
alert(`❌ Validation failed: invalid or non existent commited_in for state ${stateId}.`);
|
|
return;
|
|
}
|
|
|
|
// We must check that this transaction indeed spend the commited_in we have in the certificate
|
|
let found = false;
|
|
for (const vin of txInfo.vin) {
|
|
if (vin.txid === prevTxid) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
console.error(`Validation error: new state doesn't spend previous state commitment transaction`);
|
|
alert('❌ Validation failed: Unconsistent commitment transactions history.');
|
|
return;
|
|
}
|
|
|
|
// set found back to false for next check
|
|
found = false;
|
|
|
|
// is the state_id commited in the transaction?
|
|
for (const vout of txInfo.vout) {
|
|
console.log(vout);
|
|
if (vout.scriptpubkey_type && vout.scriptpubkey_type === 'op_return') {
|
|
found = true;
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
if (vout.scriptpubkey_asm) {
|
|
const hash = extractHexFromScriptAsm(vout.scriptpubkey_asm);
|
|
if (hash) {
|
|
if (hash !== stateId) {
|
|
console.error(`Validation error: expected stateId ${stateId}, got ${hash}`);
|
|
alert('❌ Validation failed: Transaction does not commit to that state.');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
alert('❌ Validation failed: Transaction does not contain data.');
|
|
return;
|
|
}
|
|
|
|
// set found back to false for next check
|
|
found = false;
|
|
|
|
for (const label of Object.keys(state.certificate.pcd_commitment)) {
|
|
// Compute the hash for this label
|
|
console.log(`Computing hash with label ${label}`)
|
|
const fileHex = service.getHashForFile(commitedIn, label, fileBlob);
|
|
console.log(`Found hash ${fileHex}`);
|
|
found = state.commitmentHashes.includes(fileHex);
|
|
if (found) break;
|
|
}
|
|
|
|
if (found) {
|
|
alert('✅ Validation successful: file hash found in pcd_commitment.');
|
|
} else {
|
|
alert('❌ Validation failed: file hash NOT found in pcd_commitment.');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchTransaction(txid: string): Promise<TransactionInfo> {
|
|
const url = `https://mempool.4nkweb.com/api/tx/${txid}`;
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch outspend status: ${response.statusText}`);
|
|
}
|
|
|
|
const outspend: TransactionInfo = await response.json();
|
|
return outspend;
|
|
}
|
|
|
|
function extractHexFromScriptAsm(scriptAsm: string): string | null {
|
|
const parts = scriptAsm.trim().split(/\s+/);
|
|
const last = parts[parts.length - 1];
|
|
|
|
// Basic validation: must be 64-char hex (32 bytes)
|
|
if (/^[0-9a-fA-F]{64}$/.test(last)) {
|
|
return last.toLowerCase();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|