Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/components/tokenItems/nftChild.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
const activeAction = ref<'sending' | 'minting' | 'burning' | null>(null);
const parseResult = ref(undefined as ParseResult | undefined);

const hasParityusdExtension = computed(() => {
const category = nftData.value.token?.category;
return category ? !!store.bcmrRegistries?.[category]?.extensions?.parityusd : false;
});
const isParsable = computed(() => {
const category = nftData.value.token?.category;
if (!category) return false;
Expand Down Expand Up @@ -89,20 +93,20 @@
return commitment;
})

onMounted(() => {
onMounted(async () => {
const category = nftData.value.token!.category;
appendBlockieIcon(category, `#${id.value}`);
// Parse NFT commitment if this is a parsable NFT
if (isParsable.value) {
parseResult.value = store.parseNftCommitment(category, nftData.value);
parseResult.value = await store.parseNftCommitment(category, nftData.value);
}
})

// Watch for isParsable becoming true after mount (e.g. bcmrRegistries loads async)
watch(isParsable, (nowParsable) => {
watch(isParsable, async (nowParsable) => {
if (nowParsable && !parseResult.value) {
const category = nftData.value.token!.category;
parseResult.value = store.parseNftCommitment(category, nftData.value);
parseResult.value = await store.parseNftCommitment(category, nftData.value);
}
})

Expand Down Expand Up @@ -464,7 +468,7 @@
<div v-if="displayNftInfo" class="tokenAction">
<div v-if="nftDescription" class="indentText"> {{ t('tokenItem.info.nftDescription') }} {{ nftDescription }} </div>
<div v-if="parseResult?.success && parseResult.namedFields?.length && parseResult.namedFields.length > 3">
<div>{{ t('tokenItem.info.parsedFields') }}</div>
<div>{{ hasParityusdExtension ? t('tokenItem.info.extensionNote') : t('tokenItem.info.parsedFields') }}</div>
<div v-for="(field, index) in parseResult.namedFields" :key="'parsed-field-' + index" style="white-space: pre-wrap; margin-left:15px">
{{ field.name ?? field.fieldId ?? `Field ${index}` }}: {{ field.parsedValue?.formatted ?? field.value }}
</div>
Expand Down
13 changes: 8 additions & 5 deletions src/components/tokenItems/tokenItemNFT.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
const imageLoadFailed = ref(false);
const parseResult = ref(undefined as ParseResult | undefined);

const hasParityusdExtension = computed(() => {
return !!store.bcmrRegistries?.[tokenData.value.category]?.extensions?.parityusd;
});
const isParsable = computed(() =>
store.bcmrRegistries?.[tokenData.value.category]?.nft_type === 'parsable'
);
Expand Down Expand Up @@ -130,23 +133,23 @@
selectedNfts.value = new Set(allKeys);
}

onMounted(() => {
onMounted(async () => {
appendBlockieIcon(tokenData.value.category, `#id${tokenData.value.category.slice(0, 10)}nft`);
// Parse NFT commitment if this is a parsable single NFT
if (isSingleNft.value && isParsable.value) {
const nftUtxo = tokenData.value.nfts?.[0];
if (nftUtxo) {
parseResult.value = store.parseNftCommitment(tokenData.value.category, nftUtxo);
parseResult.value = await store.parseNftCommitment(tokenData.value.category, nftUtxo);
}
}
})

// Watch for isParsable becoming true after mount (e.g. bcmrRegistries loads async)
watch(isParsable, (nowParsable) => {
watch(isParsable, async (nowParsable) => {
if (nowParsable && isSingleNft.value && !parseResult.value) {
const nftUtxo = tokenData.value.nfts?.[0];
if (nftUtxo) {
parseResult.value = store.parseNftCommitment(tokenData.value.category, nftUtxo);
parseResult.value = await store.parseNftCommitment(tokenData.value.category, nftUtxo);
}
}
})
Expand Down Expand Up @@ -726,7 +729,7 @@
<div></div>
<div v-if="tokenDescription" class="indentText">{{ t('tokenItem.info.tokenDescription') }} {{ tokenDescription }} </div>
<div v-if="parseResult?.success && parseResult.namedFields?.length && parseResult.namedFields.length > 2">
<div>{{ t('tokenItem.info.parsedFields') }}</div>
<div>{{ hasParityusdExtension ? t('tokenItem.info.extensionNote') : t('tokenItem.info.parsedFields') }}</div>
<div v-for="(field, index) in parseResult.namedFields" :key="'parsed-field-' + index" style="white-space: pre-wrap; margin-left:15px">
{{ field.name ?? field.fieldId ?? `Field ${index}` }}: {{ field.parsedValue?.formatted ?? field.value }}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"yes": "yes",
"no": "no",
"nftAttributes": "NFT attributes",
"extensionNote": "Loan data fetched from blockchain:",
"parsedFields": "Parsed NFT data:",
"numberNfts": "Number NFTs:"
},
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"yes": "sí",
"no": "no",
"nftAttributes": "Atributos del NFT",
"extensionNote": "Datos del préstamo obtenidos de la blockchain:",
"parsedFields": "Datos NFT analizados:",
"numberNfts": "Número de NFTs:"
},
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"yes": "oui",
"no": "non",
"nftAttributes": "Attributs du NFT",
"extensionNote": "Données du prêt récupérées de la blockchain :",
"parsedFields": "Données NFT analysées :",
"numberNfts": "Nombre de NFTs :"
},
Expand Down
62 changes: 62 additions & 0 deletions src/parsing/electrumAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ElectrumClient, ElectrumUtxo } from "./extensions/types";

/**
* Minimal provider interface — duck-typed to avoid deep imports from mainnet-js.
*/
interface ElectrumProvider {
getUtxos(cashaddr: string): Promise<{
txid: string;
vout: number;
satoshis: bigint;
height?: number;
token?: {
category: string;
amount: bigint;
nft?: {
capability: "none" | "mutable" | "minting";
commitment: string;
};
};
}[]>;
getRawTransaction(txHash: string): Promise<string>;
}

/**
* Create an ElectrumClient adapter from a mainnet-js ElectrumNetworkProvider.
*
* Bridges the mainnet-js Utxo format to the ElectrumUtxo format expected
* by the extension system.
*/
export function createElectrumAdapter(provider: ElectrumProvider): ElectrumClient {
return {
async getUTXOs(address: string): Promise<ElectrumUtxo[]> {
const utxos = await provider.getUtxos(address);
return utxos.map((utxo) => {
const result: ElectrumUtxo = {
tx_hash: utxo.txid,
tx_pos: utxo.vout,
value: utxo.satoshis,
script: "",
...(utxo.height !== undefined && { height: utxo.height }),
};
if (utxo.token) {
result.token_data = {
category: utxo.token.category,
amount: utxo.token.amount?.toString(),
};
if (utxo.token.nft?.capability !== undefined || utxo.token.nft?.commitment !== undefined) {
result.token_data.nft = {
capability: utxo.token.nft?.capability ?? "none",
commitment: utxo.token.nft?.commitment ?? "",
};
}
}
return result;
});
},

async getRawTransaction(txid: string): Promise<string> {
return provider.getRawTransaction(txid);
},
};
}
99 changes: 99 additions & 0 deletions src/parsing/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Output } from "@bitauth/libauth";
import type { ExtensionRegistry, ElectrumClient } from "./types";
import type { IdentitySnapshot } from "../bcmr-v2.schema";

// Import extension handlers
import { fetchLoanState } from "./parityusd";

/**
* Registry of all available extensions
*/
export const extensions: ExtensionRegistry = {
parityusd: {
fetchLoanState,
},
};

/**
* Invoke extensions declared in a BCMR identity
*
* Extensions are called in order and can modify the UTXO before NFT parsing.
* Common use case: Fetch on-chain data and transplant into UTXO commitment.
*
* @param utxo - The UTXO to process
* @param identitySnapshot - BCMR identity snapshot containing extensions config
* @param electrumClient - Electrum client for blockchain data fetching
* @param networkPrefix - Network prefix ("bitcoincash" or "bchtest")
* @param extensionsEnabled - Optional map of extension names to enabled status
* @returns Modified UTXO with extension processing applied
*/
export async function invokeExtensions(
utxo: Output,
identitySnapshot: IdentitySnapshot,
electrumClient: ElectrumClient,
networkPrefix: string,
extensionsEnabled?: Record<string, boolean>,
): Promise<Output> {
if (!identitySnapshot.extensions) {
return utxo;
}

let modifiedUtxo = utxo;

// Iterate through all extensions in the identity
for (const [extensionName, extensionConfig] of Object.entries(
identitySnapshot.extensions,
)) {
// Check if this extension is enabled (default to true if not specified)
const isEnabled = extensionsEnabled?.[extensionName] ?? true;
if (!isEnabled) {
console.log(`Extension ${extensionName} is disabled, skipping`);
continue;
}

const extensionHandlers = extensions[extensionName];
if (!extensionHandlers) {
console.warn(`Unknown extension: ${extensionName}`);
continue;
}

// Iterate through all methods in this extension
for (const methodName of Object.keys(extensionConfig as object)) {
const handler = extensionHandlers[methodName];
if (!handler) {
console.warn(
`Unknown method ${methodName} in extension ${extensionName}`,
);
continue;
}

console.log(
`Invoking extension: ${extensionName}.${methodName}`,
);

try {
modifiedUtxo = await handler(
modifiedUtxo,
identitySnapshot,
electrumClient,
networkPrefix,
);
} catch (error) {
console.error(
`Error invoking extension ${extensionName}.${methodName}:`,
error,
);
// Continue with unmodified UTXO on error
}
}
}

return modifiedUtxo;
}

// Re-export types for convenience
export type {
ElectrumClient,
ElectrumUtxo,
ExtensionHandler,
} from "./types";
Loading
Loading