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