diff --git a/src/pages/account/document-validation.ts b/src/pages/account/document-validation.ts index bd67d15..0fe233a 100644 --- a/src/pages/account/document-validation.ts +++ b/src/pages/account/document-validation.ts @@ -8,6 +8,40 @@ interface State { 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, @@ -170,7 +204,80 @@ export function getDocumentValidation(container: HTMLElement) { }; 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}`) @@ -187,4 +294,28 @@ export function getDocumentValidation(container: HTMLElement) { } } } + + 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; + } } diff --git a/src/pages/account/process-creation.ts b/src/pages/account/process-creation.ts index 134c33d..3efa382 100644 --- a/src/pages/account/process-creation.ts +++ b/src/pages/account/process-creation.ts @@ -55,7 +55,7 @@ export async function getProcessCreation(container: HTMLElement) { await service.handleApiReturn(approveChangeResult); if (approveChangeResult) { const process = await service.getProcess(processId); - const newState = service.getStateFromId(process, stateId); + let newState = service.getStateFromId(process, stateId); if (!newState) return; for (const label of Object.keys(newState.keys)) { const hash = newState.pcd_commitment[label]; @@ -70,6 +70,8 @@ export async function getProcessCreation(container: HTMLElement) { setTimeout(() => URL.revokeObjectURL(link.href), 1000); } + // Add processId to the state we export + newState['process_id'] = processId; const blob = new Blob([JSON.stringify(newState, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); diff --git a/src/services/service.ts b/src/services/service.ts index 3d9507e..3084774 100755 --- a/src/services/service.ts +++ b/src/services/service.ts @@ -1779,6 +1779,18 @@ export default class Services { } } + public getNextStateAfterId(process: Process, stateId: string): ProcessState | null { + if (process.states.length === 0) return null; + + const index = process.states.findIndex(state => state.state_id === stateId); + + if (index !== -1 && index < process.states.length - 1) { + return process.states[index + 1]; + } + + return null; + } + public isPairingProcess(roles: Record): boolean { if (Object.keys(roles).length != 1) { return false } const pairingRole = roles['pairing'];