diff --git a/offchain/package.json b/offchain/package.json index eec6436..de7dd48 100644 --- a/offchain/package.json +++ b/offchain/package.json @@ -38,6 +38,7 @@ "test": "vitest run" }, "devDependencies": { + "@meshsdk/scalus-emulator": "^1.9.1", "@parcel/packager-ts": "2.12.0", "@parcel/transformer-typescript-types": "2.12.0", "@types/node": "^20", @@ -49,6 +50,6 @@ "@sinclair/typebox": "^0.32.27" }, "peerDependencies": { - "lucid-cardano": "^0.10.10" + "@meshsdk/core": "^1.9.1" } } diff --git a/offchain/src/aiken.ts b/offchain/src/aiken.ts index 7a7baf9..231a3bc 100644 --- a/offchain/src/aiken.ts +++ b/offchain/src/aiken.ts @@ -1,10 +1,10 @@ /// Schema for aiken stdlib types added here as needed. /// Some schema definitions were liberally taken from /// https://github.com/spacebudz/nebula/blob/main/common/utils.ts -import { Constr, fromText, getAddressDetails, type Address, type Lucid } from 'lucid-cardano'; +import { deserializeAddress, pubKeyAddress, scriptAddress, serializeAddressObj, stringToHex } from '@meshsdk/core'; import { TimeWindow } from './common'; -import { Data } from './data'; +import { Constr, Data } from './data'; export const PolicyIdSchema = Data.Bytes({ minLength: 28, maxLength: 28 }); @@ -142,7 +142,7 @@ export function toTimeWindow(interval: PosixTimeIntervalType): TimeWindow { export function asChainMap(data: Record): Map { const metadata: Map = new Map(); for (const [key, value] of Object.entries(data)) { - const encodedKey = fromText(key); + const encodedKey = stringToHex(key); const encodedValue = typeof value == 'boolean' ? asChainBoolean(value) : Data.fromJson(value); metadata.set(encodedKey, encodedValue); } @@ -157,26 +157,23 @@ export function asChainBoolean(bool: boolean) { } /// Converts a Bech32 address to the aiken representation of a chain address -export function asChainAddress(address: Address): ChainAddress { - const { paymentCredential, stakeCredential } = getAddressDetails(address); +export function asChainAddress(address: string): ChainAddress { + const { pubKeyHash, scriptHash, stakeCredentialHash, stakeScriptCredentialHash } = deserializeAddress(address); - if (!paymentCredential) throw new Error('Not a valid payment address.'); + if (!pubKeyHash && !scriptHash) throw new Error('Not a valid payment address.'); + + const stakeHash = stakeCredentialHash || stakeScriptCredentialHash; return { - paymentCredential: - paymentCredential?.type === 'Key' - ? { - VerificationKeyCredential: [paymentCredential.hash], - } - : { ScriptCredential: [paymentCredential.hash] }, - stakeCredential: stakeCredential + paymentCredential: pubKeyHash + ? { VerificationKeyCredential: [pubKeyHash] } + : { ScriptCredential: [scriptHash] }, + stakeCredential: stakeHash ? { Inline: [ - stakeCredential.type === 'Key' - ? { - VerificationKeyCredential: [stakeCredential.hash], - } - : { ScriptCredential: [stakeCredential.hash] }, + stakeCredentialHash + ? { VerificationKeyCredential: [stakeCredentialHash] } + : { ScriptCredential: [stakeScriptCredentialHash] }, ], } : null, @@ -184,28 +181,32 @@ export function asChainAddress(address: Address): ChainAddress { } /// Converts a aiken chain address to a bech32 address -export function toBech32Address(lucid: Lucid, address: ChainAddress): Address { - // Slightly silly lucid contains utils which references lucid only for a single field 'network' - const { utils } = lucid; - - const paymentCredential = (() => { - if ('VerificationKeyCredential' in address.paymentCredential) { - return utils.keyHashToCredential(address.paymentCredential.VerificationKeyCredential[0]); - } else { - return utils.scriptHashToCredential(address.paymentCredential.ScriptCredential[0]); - } - })(); +export function toBech32Address(networkId: number, address: ChainAddress): string { const stakeCredential = (() => { - if (!address.stakeCredential) return undefined; - if ('Inline' in address.stakeCredential) { - if ('VerificationKeyCredential' in address.stakeCredential.Inline[0]) { - return utils.keyHashToCredential(address.stakeCredential.Inline[0].VerificationKeyCredential[0]); - } else { - return utils.scriptHashToCredential(address.stakeCredential.Inline[0].ScriptCredential[0]); - } - } else { + if (!address.stakeCredential || !('Inline' in address.stakeCredential)) { return undefined; } + + const credential = address.stakeCredential.Inline[0]; + if ('ScriptCredential' in credential) { + return { hash: credential.ScriptCredential[0], isScript: true }; + } + return { hash: credential.VerificationKeyCredential[0], isScript: false }; })(); - return utils.credentialToAddress(paymentCredential, stakeCredential); + + if ('ScriptCredential' in address.paymentCredential) { + const addressObj = scriptAddress( + address.paymentCredential.ScriptCredential[0], + stakeCredential?.hash, + stakeCredential?.isScript + ); + return serializeAddressObj(addressObj, networkId); + } + + const addressObj = pubKeyAddress( + address.paymentCredential.VerificationKeyCredential[0], + stakeCredential?.hash, + stakeCredential?.isScript + ); + return serializeAddressObj(addressObj, networkId); } diff --git a/offchain/src/cip-102.ts b/offchain/src/cip-102.ts index 18e345a..522e7d4 100644 --- a/offchain/src/cip-102.ts +++ b/offchain/src/cip-102.ts @@ -1,11 +1,13 @@ -import { fromText, toUnit, type Tx } from 'lucid-cardano'; +import { stringToHex, type MeshTxBuilder } from '@meshsdk/core'; import { asChainAddress, ChainAddressSchema } from './aiken'; import { Data } from './data'; import { Royalty } from './royalty'; +import type { ScriptInfo } from './script'; +import { toUnit } from './utils'; export const ROYALTY_TOKEN_LABEL = 500; -export const ROYALTY_TOKEN_NAME = fromText('Royalty'); +export const ROYALTY_TOKEN_NAME = stringToHex('Royalty'); export const RoyaltyRecipientSchema = Data.Object({ address: ChainAddressSchema, @@ -39,8 +41,7 @@ export function asChainVariableFee(percent: number) { /// Converts from a on chain royalty to a percent between 0 and 100 export function fromChainVariableFee(fee: bigint) { - if(fee <= 0) - throw new Error("Chain fee can't be zero or below."); + if (fee <= 0) throw new Error("Chain fee can't be zero or below."); return Math.ceil(Number(10000n / fee)) / 10; } @@ -83,16 +84,20 @@ export function toCip102RoyaltyDatum(royalties: Royalty[]) { } export function addCip102RoyaltyToTransaction( - tx: Tx, - policyId: string, + tx: MeshTxBuilder, + mintScript: ScriptInfo, address: string, royalties: Royalty[], - redeemer?: string + redeemer: string ) { - const royaltyUnit = toRoyaltyUnit(policyId); - const royaltyAsset = { [royaltyUnit]: 1n }; + const royaltyUnit = toRoyaltyUnit(mintScript.policyId); + const assetName = royaltyUnit.slice(mintScript.policyId.length); const royaltyDatum = toCip102RoyaltyDatum(royalties); - const royaltyOutputData = { inline: royaltyDatum }; - tx.mintAssets(royaltyAsset, redeemer).payToAddressWithData(address, royaltyOutputData, royaltyAsset); + tx.mintPlutusScript(mintScript.script.version) + .mint('1', mintScript.policyId, assetName) + .mintingScript(mintScript.script.code) + .mintRedeemerValue(redeemer, 'CBOR') + .txOut(address, [{ unit: royaltyUnit, quantity: '1' }]) + .txOutInlineDatumValue(royaltyDatum, 'CBOR'); } diff --git a/offchain/src/cip-88.test.ts b/offchain/src/cip-88.test.ts index 3e52d1e..95d4bc7 100644 --- a/offchain/src/cip-88.test.ts +++ b/offchain/src/cip-88.test.ts @@ -11,19 +11,22 @@ import { TokenProjectDetailField, } from './cip-88'; import { parameterizeMintingPolicy } from './contract'; -import contractsUrl from './contracts-url'; import { TEST_COLLECTION_INFO } from './fixtures.test'; import { fetchContracts } from './script'; -import { createEmulatorLucid } from './support.test'; +import { createEmulatorContext } from './support.test'; test('Build CIP-88 metadata', async () => { - const { lucid, seedUtxo } = await createEmulatorLucid(); + const { mesh, seedUtxo } = await createEmulatorContext(); - const contracts = await fetchContracts(contractsUrl); + if (!mesh.wallet) { + throw new Error('Test context must have a wallet selected'); + } - const mintingPolicy = parameterizeMintingPolicy(lucid, contracts, seedUtxo.txHash, seedUtxo.outputIndex); + const contracts = await fetchContracts('local'); + + const mintingPolicy = parameterizeMintingPolicy(mesh, contracts, seedUtxo.input.txHash, seedUtxo.input.outputIndex); const policyId = `0x${mintingPolicy.policyId}`; - const address = await lucid.wallet.address(); + const address = await mesh.wallet.getChangeAddress(); const info = TEST_COLLECTION_INFO; const royalty = { address, variableFee: 1.2 }; const assetName = ''; @@ -35,7 +38,7 @@ test('Build CIP-88 metadata', async () => { .cip102Royalties([royalty]) .oracle(oracleUri) .validateWithbeacon(mintingPolicy.policyId) - .build(lucid); + .build(mesh); expect(metadata[RegistrationMetadataField.VERSION] === 1, 'CIP 88 Version 1'); expect(metadata[RegistrationMetadataField.WITNESS][0].length === 0, 'Beacon validation uses empty witness set'); diff --git a/offchain/src/cip-88.ts b/offchain/src/cip-88.ts index 1b1f6a1..e5d4f63 100644 --- a/offchain/src/cip-88.ts +++ b/offchain/src/cip-88.ts @@ -1,11 +1,12 @@ /// CIP-88 definitions and builder -import { fromUnit, type Lucid, type Script, type Tx } from 'lucid-cardano'; +import { resolveScriptHash, type MeshTxBuilder, type PlutusScript } from '@meshsdk/core'; import { asChainVariableFee } from './cip-102'; import { CollectionImage, CollectionInfo } from './collection-info'; import { IMAGE_PURPOSE } from './image'; +import type { MeshContext } from './mesh'; import { Royalty } from './royalty'; -import { chunk } from './utils'; +import { chunk, fromUnit, removeEmpty } from './utils'; export const CIP_88_METADATA_LABEL = 867; @@ -271,16 +272,16 @@ export function toCip102RoyaltyDetail(royalties: Royalty[]): Cip102RoyaltyDetail } export class Cip88Builder { - #script: Script; + #script: PlutusScript; #beacon?: string; #features: RegistrationFeatureDetails = {}; #oracle?: string; - private constructor(script: Script) { + private constructor(script: PlutusScript) { this.#script = script; } - public static register(script: Script) { + public static register(script: PlutusScript) { return new Cip88Builder(script); } @@ -316,21 +317,26 @@ export class Cip88Builder { return this; } - async build(lucid: Lucid): Promise { - if (!lucid.wallet) { - throw new Error('Must provide an instance of lucide with a selected wallet'); + async build(mesh: MeshContext): Promise { + if (!mesh.wallet) { + throw new Error('Must provide a mesh context with a selected wallet'); } - const payload = this.buildPayload(lucid); + const payload = this.buildPayload(); + + // Optional fields (e.g. a missing banner image) are present as explicit `undefined` values in + // the built objects; Mesh's metadata converter throws on those, so strip them before the payload + // is signed or embedded so the signed bytes match what actually ends up on-chain. + removeEmpty(payload); // FIXME: I am not sure if we leave the witness section empty when we use a beacon token. let witness: string[][] = [[]]; if (!this.#beacon) { - const address = await lucid.wallet.address(); + const address = await mesh.wallet.getChangeAddress(); // FIXME: This seems wrong. The standard under 3. Validation Method says: // "The payload to be signed should be the hex-encoded CBOR representation of the Registration Payload object." // so I think I might need to declare a complete data schema for CIP-88 and use Data.to(...) to get correct cbor hex. - const result = await lucid.wallet.signMessage(address, JSON.stringify(payload)); + const result = await mesh.wallet.signData(JSON.stringify(payload), address); // Probably should also add `0x` to these to convert them into byte arrays in the metadata witness = [[result.key, result.signature]]; } @@ -353,8 +359,8 @@ export class Cip88Builder { return this; } - private buildPayload(lucid: Lucid): RegistrationPayload { - const scope = this.buildScope(lucid); + private buildPayload(): RegistrationPayload { + const scope = this.buildScope(); let validationMethod: RegistrationValidationMethod | undefined; if (this.#beacon) { const { policyId, assetName } = fromUnit(this.#beacon); @@ -379,26 +385,23 @@ export class Cip88Builder { }; } - private buildScope(lucid: Lucid): RegistrationScope { - const policyId = `0x${lucid.utils.validatorToScriptHash(this.#script)}`; + private buildScope(): RegistrationScope { + const policyId = `0x${resolveScriptHash(this.#script.code, this.#script.version)}`; - if (this.#script.type === 'Native') { - const scriptChunks = chunk(this.#script.script, 64, '0x'); - return [SCOPE_NATIVE, [policyId, scriptChunks]]; - } else if (this.#script.type === 'PlutusV1') { + if (this.#script.version === 'V1') { return [SCOPE_PLUTUS_V1, [policyId]]; - } else if (this.#script.type === 'PlutusV2') { + } else if (this.#script.version === 'V2') { return [SCOPE_PLUTUS_V2, [policyId]]; } - throw Error(`Could not determine scope. Unexpected script type ${this.#script.type}`); + throw Error(`Could not determine scope. Unexpected script version ${this.#script.version}`); } } export async function addCip88MetadataToTransaction( - lucid: Lucid, - tx: Tx, - script: Script, + mesh: MeshContext, + tx: MeshTxBuilder, + script: PlutusScript, beaconUnit: string, config: Cip88ExtraConfig | undefined = undefined ) { @@ -418,6 +421,10 @@ export async function addCip88MetadataToTransaction( } } - const metadata = await builder.build(lucid); - return tx.attachMetadataWithConversion(CIP_88_METADATA_LABEL, metadata); + const metadata = await builder.build(mesh); + // NOTE: lucid-cardano's attachMetadataWithConversion converted any '0x'-prefixed string into raw + // bytes in the CBOR metadata; Mesh has no equivalent, so '0x'-prefixed values here (policyId, + // witness key/signature) are encoded as plain text instead of bytes. See the FIXMEs above -- this + // encoding was already flagged as uncertain before this migration. + return tx.metadataValue(CIP_88_METADATA_LABEL, metadata); } diff --git a/offchain/src/collection-info.ts b/offchain/src/collection-info.ts index 07c7017..0a15acb 100644 --- a/offchain/src/collection-info.ts +++ b/offchain/src/collection-info.ts @@ -1,10 +1,11 @@ -import { fromText, toText, type Lucid, type UTxO } from 'lucid-cardano'; +import { hexToString, stringToHex, type UTxO } from '@meshsdk/core'; import { toBech32Address } from './aiken'; import { createReferenceData } from './cip-68'; import { fromChainVariableFee, RoyaltyInfoShape, RoyaltyInfoType, toRoyaltyUnit } from './cip-102'; import { Data } from './data'; import { IMAGE_PURPOSE, ImageDimension, ImagePurpose } from './image'; +import { fetchUtxoByUnit, networkId, type MeshContext } from './mesh'; import { Royalty } from './royalty'; import { ScriptCache } from './script'; import { asChunkedHex, toJoinedText } from './utils'; @@ -85,7 +86,7 @@ export function asChainCollectionImage(image: CollectionImage): CollectionImageT const dimension = image.dimension ? { width: BigInt(image.dimension.width), height: BigInt(image.dimension.height) } : null; - const media_type = image.mediaType ? fromText(image.mediaType) : null; + const media_type = image.mediaType ? stringToHex(image.mediaType) : null; const src = asChunkedHex(image.src); return { @@ -101,7 +102,7 @@ export function toCollectionImage(chainImage: CollectionImageType): CollectionIm const dimension = chainImage.dimension ? { width: Number(chainImage.dimension.width), height: Number(chainImage.dimension.height) } : undefined; - const mediaType = chainImage.media_type ? toText(chainImage.media_type) : undefined; + const mediaType = chainImage.media_type ? hexToString(chainImage.media_type) : undefined; const src = toJoinedText(chainImage.src); return { @@ -113,18 +114,18 @@ export function toCollectionImage(chainImage: CollectionImageType): CollectionIm } export function asChainCollectionInfo(info: CollectionInfo): CollectionInfoMetadataType { - const name = fromText(info.name); - const artist = info.artist ? fromText(info.artist) : null; - const project = info.project ? fromText(info.project) : null; + const name = stringToHex(info.name); + const artist = info.artist ? stringToHex(info.artist) : null; + const project = info.project ? stringToHex(info.project) : null; const nsfw = info.nsfw ? true : false; const description = info.description ? asChunkedHex(info.description) : []; const images = info.images ? info.images.map(asChainCollectionImage) : []; - const traits = info.traits ? info.traits.map(fromText) : []; + const traits = info.traits ? info.traits.map(stringToHex) : []; const links = new Map(); if (info.links) { for (const [key, value] of Object.entries(info.links)) { - links.set(fromText(key), asChunkedHex(value)); + links.set(stringToHex(key), asChunkedHex(value)); } } @@ -147,20 +148,20 @@ export function asChainCollectionInfo(info: CollectionInfo): CollectionInfoMetad export function toCollectionInfo(chainInfo: CollectionInfoMetadataType): CollectionInfo { const { metadata } = chainInfo; - const name = toText(metadata.name); - const artist = metadata.artist ? toText(metadata.artist) : undefined; - const project = metadata.project ? toText(metadata.project) : undefined; + const name = hexToString(metadata.name); + const artist = metadata.artist ? hexToString(metadata.artist) : undefined; + const project = metadata.project ? hexToString(metadata.project) : undefined; const nsfw = metadata.nsfw; const description = metadata.description.length ? toJoinedText(metadata.description) : undefined; const images = metadata.images.length ? metadata.images.map(toCollectionImage) : undefined; - const traits = metadata.traits.length ? metadata.traits.map(toText) : undefined; + const traits = metadata.traits.length ? metadata.traits.map(hexToString) : undefined; let links: Record | undefined = undefined; if (metadata.links) { links = {}; for (const [key, value] of metadata.links) { - links[toText(key)] = toJoinedText(value); + links[hexToString(key)] = toJoinedText(value); } } @@ -169,7 +170,7 @@ export function toCollectionInfo(chainInfo: CollectionInfoMetadataType): Collect extra = {}; for (const [key, value] of metadata.extra) { - extra[toText(key)] = value; + extra[hexToString(key)] = value; } } @@ -187,17 +188,21 @@ export function toCollectionInfo(chainInfo: CollectionInfoMetadataType): Collect } // Decode CBOR datum of passed in UTxO and then convert the plutus data into offchain data -export async function extractCollectionInfo(lucid: Lucid, utxo: UTxO) { - const chainInfo = await lucid.datumOf(utxo, CollectionInfoMetadataShape); +export async function extractCollectionInfo(utxo: UTxO) { + if (!utxo.output.plutusData) { + throw new Error('UTxO does not have an inline datum to decode collection info from.'); + } + + const chainInfo = Data.from(utxo.output.plutusData, CollectionInfoMetadataShape); return toCollectionInfo(chainInfo); } -export function toRoyaltyInfo(lucid: Lucid, chainInfo: RoyaltyInfoType): Royalty[] { +export function toRoyaltyInfo(network: number, chainInfo: RoyaltyInfoType): Royalty[] { const { metadata } = chainInfo; let royalties: Royalty[] = []; metadata.forEach((royalty) => { royalties.push({ - address: toBech32Address(lucid, royalty.address), + address: toBech32Address(network, royalty.address), variableFee: fromChainVariableFee(royalty.variableFee), minFee: royalty.minFee ? Number(royalty.minFee) : undefined, maxFee: royalty.maxFee ? Number(royalty.maxFee) : undefined, @@ -207,12 +212,12 @@ export function toRoyaltyInfo(lucid: Lucid, chainInfo: RoyaltyInfoType): Royalty return royalties; } -export async function extractRoyaltyInfo(lucid: Lucid, policy: string) { +export async function extractRoyaltyInfo(mesh: MeshContext, policy: string) { try { - const utxo = await lucid.utxoByUnit(toRoyaltyUnit(policy)); - if (utxo) { - const chainInfo = await lucid.datumOf(utxo, RoyaltyInfoShape); - return toRoyaltyInfo(lucid, chainInfo); + const utxo = await fetchUtxoByUnit(mesh.provider, toRoyaltyUnit(policy)); + if (utxo && utxo.output.plutusData) { + const chainInfo = Data.from(utxo.output.plutusData, RoyaltyInfoShape); + return toRoyaltyInfo(networkId(mesh), chainInfo); } else return undefined; } catch (err) { console.log('Error getting royalties'); @@ -222,11 +227,11 @@ export async function extractRoyaltyInfo(lucid: Lucid, policy: string) { } export async function fetchCollectionInfo(cache: ScriptCache) { - const lucid = cache.lucid(); + const mesh = cache.mesh(); const unit = cache.unit().info; - const utxo = await lucid.utxoByUnit(unit); + const utxo = await fetchUtxoByUnit(mesh.provider, unit); if (utxo) { - return extractCollectionInfo(lucid, utxo); + return extractCollectionInfo(utxo); } else { return undefined; } diff --git a/offchain/src/collection-state.ts b/offchain/src/collection-state.ts index 824f03d..27772e2 100644 --- a/offchain/src/collection-state.ts +++ b/offchain/src/collection-state.ts @@ -1,6 +1,6 @@ /// On chain schema for the collection state data. For more details on the purpose of the -import { type Lucid, type UTxO } from 'lucid-cardano'; +import { type UTxO } from '@meshsdk/core'; import { asChainAddress, @@ -75,13 +75,17 @@ export function toStateUnit(policyId: string) { } // Decode CBOR datum of passed in UTxO and then convert the plutus data into offchain data -export async function extractCollectionState(lucid: Lucid, utxo: UTxO) { - const chainState = await lucid.datumOf(utxo, CollectionStateMetadataShape); - return toCollectionState(lucid, chainState); +export async function extractCollectionState(utxo: UTxO, networkId: number) { + if (!utxo.output.plutusData) { + throw new Error('UTxO does not have an inline datum to decode collection state from.'); + } + + const chainState = Data.from(utxo.output.plutusData, CollectionStateMetadataShape); + return toCollectionState(chainState, networkId); } /// Convert from on chain plutus data to off chain data structure -export function toCollectionState(lucid: Lucid, chainState: CollectionStateMetadataType): CollectionState { +export function toCollectionState(chainState: CollectionStateMetadataType, networkId: number): CollectionState { const { metadata } = chainState; const chainInfo = metadata.info; @@ -92,7 +96,7 @@ export function toCollectionState(lucid: Lucid, chainState: CollectionStateMetad mintWindow: chainInfo.mint_window ? toTimeWindow(chainInfo.mint_window) : undefined, maxNfts: chainInfo.max_nfts ? Number(chainInfo.max_nfts) : undefined, nftValidatorAddress: chainInfo.nft_validator_address - ? toBech32Address(lucid, chainInfo.nft_validator_address) + ? toBech32Address(networkId, chainInfo.nft_validator_address) : undefined, scriptReferencePolicyId: chainInfo.script_reference_policy_id, }; diff --git a/offchain/src/collection.ts b/offchain/src/collection.ts index 0711f8d..dc7d285 100644 --- a/offchain/src/collection.ts +++ b/offchain/src/collection.ts @@ -1,6 +1,7 @@ -import { fromText, toLabel, toUnit } from 'lucid-cardano'; +import { stringToHex } from '@meshsdk/core'; import { NFT_TOKEN_LABEL, REFERENCE_TOKEN_LABEL } from './cip-68'; +import { toLabel, toUnit } from './utils'; /// Speical collection owner token label. /// Selected because it has similar pattern to other labels, there is no standard. @@ -52,7 +53,7 @@ function toNftAssetName(label: number, sequence: number, content: string) { content = content.substring(0, CONTENT_NAME_MAX_BYTES); } - return `${toLabel(label)}${toPurposeHex(COLLECTION_TOKEN_PURPOSE.NFT)}${toSequenceHex(sequence)}${fromText(content)}`; + return `${toLabel(label)}${toPurposeHex(COLLECTION_TOKEN_PURPOSE.NFT)}${toSequenceHex(sequence)}${stringToHex(content)}`; } /// Construct a unit from the collection specific parts that compose the unit name @@ -82,7 +83,7 @@ export function toNftUserUnit(policyId: string, sequence: number, content: strin /// Construct management token unit from policy id and label function toManageUnit(policyId: string, label: number) { - const name = `${toPurposeHex(COLLECTION_TOKEN_PURPOSE.Management)}${fromText('Collection')}`; + const name = `${toPurposeHex(COLLECTION_TOKEN_PURPOSE.Management)}${stringToHex('Collection')}`; return toUnit(policyId, name, label); } diff --git a/offchain/src/contract.ts b/offchain/src/contract.ts index 73494ff..854b7c4 100644 --- a/offchain/src/contract.ts +++ b/offchain/src/contract.ts @@ -1,18 +1,20 @@ -import { applyParamsToScript, fromText, Script, toLabel, UTxO, type Lucid } from 'lucid-cardano'; +import { applyParamsToScript, stringToHex, type MeshTxBuilder, type PlutusScript, type UTxO } from '@meshsdk/core'; import { OutputReferenceSchema, PolicyIdSchema, PubKeyHashSchema } from './aiken'; -import { Data } from './data'; -import { Contracts, fetchOwnerUtxo, fetchUtxo, getScript, getScriptInfo, ScriptCache } from './script'; +import { Data, toMeshParams } from './data'; +import { newTxBuilder, unixTimeToSlot, type MeshContext } from './mesh'; +import { fetchOwnerUtxo, fetchUtxo, getScript, getScriptInfo, ScriptCache, type Contracts } from './script'; +import { toLabel } from './utils'; export const ScriptName = { - NftMintingPolicy: 'mint.mint', - DerivativeMintingPolicy: 'derivative.mint', - DelegateMintingPolicy: 'delegate.mint', - MintingStateValidator: 'state.spend', - ImmutableInfoValidator: 'immutable_info.spend', - ImmutableNftValidator: 'immutable_nft.spend', - PermissiveNftValidator: 'permissive_nft.spend', - SpendLockValidator: 'lock.spend', + NftMintingPolicy: 'mint.mint_script.mint', + DerivativeMintingPolicy: 'derivative.derivative_script.mint', + DelegateMintingPolicy: 'delegate.delegate_script.mint', + MintingStateValidator: 'state.state_script.spend', + ImmutableInfoValidator: 'immutable_info.imm_info_script.spend', + ImmutableNftValidator: 'immutable_nft.imm_nft_script.spend', + PermissiveNftValidator: 'permissive_nft.perm_nft_script.spend', + SpendLockValidator: 'lock.lock_script.spend', } as const; /// Minting policy paramaterization schema @@ -49,18 +51,18 @@ export const StateValidatorRedeemerShape = StateValidatorRedeemerSchema as unkno /// Given a minting policy, parameterizes the collection info reference token spending validator and returns its info function paramaterizePolicyIdValidator( - lucid: Lucid, + mesh: MeshContext, contracts: Contracts, mintingPolicyId: string, scriptName: string ) { const script = getScript(contracts, scriptName); - const paramertizedMintingPolicy = applyParamsToScript( + const paramertizedMintingPolicy = applyParamsToScript( script.compiledCode, - [mintingPolicyId], - PolicyIdValidatorParameterShape + toMeshParams([mintingPolicyId], PolicyIdValidatorParameterShape), + 'Mesh' ); - return getScriptInfo(lucid, script.title, paramertizedMintingPolicy); + return getScriptInfo(mesh, script.title, paramertizedMintingPolicy); } // Script references are not created under the same main minting policy as the collection and nft tokens. @@ -68,19 +70,23 @@ function paramaterizePolicyIdValidator( // In other words the policyId first parameter below is NOT the nft/collection token minting policy id. It's just // some native script minting policy. export function scriptReferenceUnit(policyId: string, assetName: string) { - return policyId + toLabel(0) + fromText(assetName); + return policyId + toLabel(0) + stringToHex(assetName); } // Creates a transaction that uses the passed in minting policy to generate tokens containing // script references and sends them to a locked spending validator unique to the main minting policy. export async function createScriptReference( - lucid: Lucid, + mesh: MeshContext, cache: ScriptCache, - scripts: { script: Script; name: string }[], + scripts: { script: PlutusScript; name: string }[], type: 'delegate' | 'derivative' = 'derivative', delegate?: string, ownerUtxo?: UTxO -) { +): Promise { + if (!mesh.wallet) { + throw new Error('Must have a wallet selected to build a transaction.'); + } + const mintingPolicyInfo = getReferenceMintInfo(cache, type, delegate); const mintingPolicyId = mintingPolicyInfo.policyId; const lockAddress = cache.spendLock().address; @@ -94,48 +100,45 @@ export async function createScriptReference( ownerUtxo = utxo; } - const assets: Record = {}; + const tx = newTxBuilder(mesh); + tx.changeAddress(await mesh.wallet.getChangeAddress()); + for (const { name } of scripts) { const unit = scriptReferenceUnit(mintingPolicyId, name); - assets[unit] = 1n; + const assetName = unit.slice(mintingPolicyId.length); + tx.mintPlutusScript(mintingPolicyInfo.script.version) + .mint('1', mintingPolicyId, assetName) + .mintingScript(mintingPolicyInfo.script.code) + .mintRedeemerValue(Data.void(), 'CBOR'); } - const tx = await lucid - .newTx() - .attachMintingPolicy(mintingPolicyInfo.script) - .mintAssets(assets, Data.void()) - .validTo(Date.now() + 2 * 60 * 1000); + tx.invalidHereafter(unixTimeToSlot(mesh, Date.now() + 2 * 60 * 1000)); // Spend the owner token if it is included to prove ownership if (ownerUtxo) { - tx.payToAddress(ownerUtxo.address, { [cache.unit().owner]: 1n }); + tx.txOut(ownerUtxo.output.address, [{ unit: cache.unit().owner, quantity: '1' }]); } for (const { script, name } of scripts) { const unit = scriptReferenceUnit(mintingPolicyId, name); - tx.payToContract( - lockAddress, - { - inline: Data.void(), - scriptRef: script, - }, - { [unit]: 1n } - ); + tx.txOut(lockAddress, [{ unit, quantity: '1' }]) + .txOutInlineDatumValue(Data.void(), 'CBOR') + .txOutReferenceScript(script.code, script.version); } return tx; } export async function fetchReferenceUtxo(cache: ScriptCache, scriptName: string, delegate?: string) { - const lucid = cache.lucid(); + const mesh = cache.mesh(); const address = cache.spendLock().address; const policyId = delegate ? cache.delegateMint(delegate).policyId : cache.derivativeMint().policyId; const unit = scriptReferenceUnit(policyId, scriptName); - return await fetchUtxo(lucid, address, unit); + return await fetchUtxo(mesh, address, unit); } /// Given a unique hash and index from the seed transaction parameterizes the minting policy and returns its info -export function parameterizeMintingPolicy(lucid: Lucid, contracts: Contracts, hash: string, index: number) { +export function parameterizeMintingPolicy(mesh: MeshContext, contracts: Contracts, hash: string, index: number) { const seed = { transaction_id: { hash, @@ -144,12 +147,12 @@ export function parameterizeMintingPolicy(lucid: Lucid, contracts: Contracts, ha }; const script = getScript(contracts, ScriptName.NftMintingPolicy); - const paramertizedMintingPolicy = applyParamsToScript( + const paramertizedMintingPolicy = applyParamsToScript( script.compiledCode, - [seed], - MintParameterShape + toMeshParams([seed], MintParameterShape), + 'Mesh' ); - return getScriptInfo(lucid, script.title, paramertizedMintingPolicy); + return getScriptInfo(mesh, script.title, paramertizedMintingPolicy); } ///// Rest of these functions are just some utility functions for readability that aren't strictly needed. @@ -164,21 +167,21 @@ export async function fetchMintingPolicyReferenceUtxo(cache: ScriptCache, delega } export async function createMintingPolicyReference( - lucid: Lucid, + mesh: MeshContext, cache: ScriptCache, type: 'delegate' | 'derivative' = 'derivative', delegate?: string, ownerUtxo?: UTxO -) { +): Promise { const script = cache.mint().script; const name = ScriptName.NftMintingPolicy; - return await createScriptReference(lucid, cache, [{ script, name }], type, delegate, ownerUtxo); + return await createScriptReference(mesh, cache, [{ script, name }], type, delegate, ownerUtxo); } /// State Validator -export function parameterizeStateValidator(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.MintingStateValidator); +export function parameterizeStateValidator(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.MintingStateValidator); } export function stateValidatorReferenceUnit(policyId: string) { @@ -190,21 +193,21 @@ export async function fetchStateValidatorReferenceUtxo(cache: ScriptCache, deleg } export async function createStateValidatorReference( - lucid: Lucid, + mesh: MeshContext, cache: ScriptCache, type: 'delegate' | 'derivative' = 'derivative', delegate?: string, ownerUtxo?: UTxO -) { +): Promise { const script = cache.state().script; const name = ScriptName.MintingStateValidator; - return await createScriptReference(lucid, cache, [{ script, name }], type, delegate, ownerUtxo); + return await createScriptReference(mesh, cache, [{ script, name }], type, delegate, ownerUtxo); } /// Immutable Info Validator -export function parameterizeImmutableInfoValidator(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.ImmutableInfoValidator); +export function parameterizeImmutableInfoValidator(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.ImmutableInfoValidator); } export function immutableInfoReferenceUnit(policyId: string) { @@ -216,8 +219,8 @@ export async function fetchImmutableInfoReferenceUtxo(cache: ScriptCache, delega } /// Immutable NFT validator -export function parameterizeImmutableNftValidator(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.ImmutableNftValidator); +export function parameterizeImmutableNftValidator(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.ImmutableNftValidator); } export function immutableNftReferenceUnit(policyId: string) { @@ -229,8 +232,8 @@ export async function fetchImmutableNftReferenceUtxo(cache: ScriptCache, delegat } /// Permissive NFT validator -export function parameterizePermissiveNftValidator(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.PermissiveNftValidator); +export function parameterizePermissiveNftValidator(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.PermissiveNftValidator); } export function permissiveNftReferenceUnit(policyId: string) { @@ -242,8 +245,8 @@ export async function fetchPermissiveNftReferenceUtxo(cache: ScriptCache, delega } /// Lock Validator -export function parameterizeSpendLockValidator(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.SpendLockValidator); +export function parameterizeSpendLockValidator(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.SpendLockValidator); } export function lockReferenceUnit(policyId: string) { @@ -255,24 +258,24 @@ export async function fetchLockReferenceUtxo(cache: ScriptCache, delegate?: stri } // Reference minting policy -export function parameterizeDerivativeMintingPolicy(lucid: Lucid, contracts: Contracts, mintingPolicyId: string) { - return paramaterizePolicyIdValidator(lucid, contracts, mintingPolicyId, ScriptName.DerivativeMintingPolicy); +export function parameterizeDerivativeMintingPolicy(mesh: MeshContext, contracts: Contracts, mintingPolicyId: string) { + return paramaterizePolicyIdValidator(mesh, contracts, mintingPolicyId, ScriptName.DerivativeMintingPolicy); } // Reference minting policy export function parameterizeDelegativeMintingPolicy( - lucid: Lucid, + mesh: MeshContext, contracts: Contracts, mintingPolicyId: string, delegatePubKeyHash: string ) { const script = getScript(contracts, ScriptName.DelegateMintingPolicy); - const paramertizedMintingPolicy = applyParamsToScript( + const paramertizedMintingPolicy = applyParamsToScript( script.compiledCode, - [mintingPolicyId, delegatePubKeyHash], - DelegateValidatorParameterShape + toMeshParams([mintingPolicyId, delegatePubKeyHash], DelegateValidatorParameterShape), + 'Mesh' ); - return getScriptInfo(lucid, script.title, paramertizedMintingPolicy); + return getScriptInfo(mesh, script.title, paramertizedMintingPolicy); } // Utility for fetching minting policy given a type and script cache diff --git a/offchain/src/data.ts b/offchain/src/data.ts index cdf5a80..f3e37a5 100644 --- a/offchain/src/data.ts +++ b/offchain/src/data.ts @@ -1,7 +1,7 @@ -/** Copied from lucid-cardano@0.10.7/src/plutus/data.ts. The compiler complains with an error like: - * error TS2742: The inferred type of 'MyLocalSchema' cannot be named without a reference to '.../node_modules/lucid-cardano/typebox' - * - * I think pomething to do with how lucid exports typebox but for now easiest workaround is to just use NPM version of typebox. +/** Originally copied from lucid-cardano@0.10.7/src/plutus/data.ts to work around a TS inference bug + * where the inferred type of a schema could not be named without a reference to lucid's bundled typebox. + * Kept as our own schema DSL across the Mesh SDK migration since none of it is lucid-specific -- only + * the low-level to()/from() (de)serialization below is tied to a particular Cardano library. */ import { Type, @@ -12,7 +12,21 @@ import { type TProperties, type TSchema, } from '@sinclair/typebox'; -import { C, fromHex, fromText, toHex, type Datum, type Exact, type Json, type Redeemer } from 'lucid-cardano'; +import { + bytesToHex, + deserializeDatum, + hexToBytes, + hexToString, + serializeData, + stringToHex, + type Data as MeshData, +} from '@meshsdk/core'; + +/// Prevents excess properties from being inferred as part of the type. Mirrors lucid-cardano's Exact. +export type Exact = T extends infer U ? U : never; + +/// Loose JSON type used internally when walking schema shapes. +export type Json = any; export class Constr { index: number; @@ -229,98 +243,76 @@ export const Data = { * Note Constr cannot be used here, also only bytes/integers as Json keys.\ */ toJson, - void: function (): Datum | Redeemer { + void: function (): string { return 'd87980'; }, castFrom, castTo, }; +/// Converts our Constr-based Data union into Mesh's structurally equivalent Data type. +function toMeshData(data: Data): MeshData { + if (data instanceof Constr) { + return { alternative: data.index, fields: data.fields.map(toMeshData) }; + } else if (data instanceof Array) { + return data.map(toMeshData); + } else if (data instanceof Map) { + const map: Map = new Map(); + for (const [key, value] of data.entries()) { + map.set(toMeshData(key), toMeshData(value)); + } + return map; + } + return data; +} + +/// Converts Mesh's Data type back into our Constr-based Data union. +function fromMeshData(data: MeshData): Data { + if (typeof data === 'number') { + return BigInt(data); + } else if (typeof data === 'bigint' || typeof data === 'string') { + return data; + } else if (data instanceof Array) { + return data.map(fromMeshData); + } else if (data instanceof Map) { + const map: Map = new Map(); + for (const [key, value] of data.entries()) { + map.set(fromMeshData(key), fromMeshData(value)); + } + return map; + } + return new Constr(data.alternative, data.fields.map(fromMeshData)); +} + /** * Convert PlutusData to Cbor encoded data.\ * Or apply a shape and convert the provided data struct to Cbor encoded data. */ -function to(data: Exact, type?: T): Datum | Redeemer { - function serialize(data: Data): C.PlutusData { - try { - if (typeof data === 'bigint') { - return C.PlutusData.new_integer(C.BigInt.from_str(data.toString())); - } else if (typeof data === 'string') { - return C.PlutusData.new_bytes(fromHex(data)); - } else if (data instanceof Constr) { - const { index, fields } = data; - const plutusList = C.PlutusList.new(); - - fields.forEach((field) => plutusList.add(serialize(field))); - - return C.PlutusData.new_constr_plutus_data( - C.ConstrPlutusData.new(C.BigNum.from_str(index.toString()), plutusList) - ); - } else if (data instanceof Array) { - const plutusList = C.PlutusList.new(); - - data.forEach((arg) => plutusList.add(serialize(arg))); - - return C.PlutusData.new_list(plutusList); - } else if (data instanceof Map) { - const plutusMap = C.PlutusMap.new(); - - for (const [key, value] of data.entries()) { - plutusMap.insert(serialize(key), serialize(value)); - } - - return C.PlutusData.new_map(plutusMap); - } - throw new Error('Unsupported type'); - } catch (error) { - throw new Error('Could not serialize the data: ' + error); - } - } +function to(data: Exact, type?: T): string { const d = type ? castTo(data, type) : (data as Data); - return toHex(serialize(d).to_bytes()) as Datum | Redeemer; + return serializeData(toMeshData(d)); } /** * Convert Cbor encoded data to Data.\ * Or apply a shape and cast the cbor encoded data to a certain type. */ -function from(raw: Datum | Redeemer, type?: T): T { - function deserialize(data: C.PlutusData): Data { - if (data.kind() === 0) { - const constr = data.as_constr_plutus_data()!; - const l = constr.data(); - const desL = []; - for (let i = 0; i < l.len(); i++) { - desL.push(deserialize(l.get(i))); - } - return new Constr(parseInt(constr.alternative().to_str()), desL); - } else if (data.kind() === 1) { - const m = data.as_map()!; - const desM: Map = new Map(); - const keys = m.keys(); - for (let i = 0; i < keys.len(); i++) { - desM.set(deserialize(keys.get(i)), deserialize(m.get(keys.get(i))!)); - } - return desM; - } else if (data.kind() === 2) { - const l = data.as_list()!; - const desL = []; - for (let i = 0; i < l.len(); i++) { - desL.push(deserialize(l.get(i))); - } - return desL; - } else if (data.kind() === 3) { - return BigInt(data.as_integer()!.to_str()); - } else if (data.kind() === 4) { - return toHex(data.as_bytes()!); - } - throw new Error('Unsupported type'); - } - const data = deserialize(C.PlutusData.from_bytes(fromHex(raw))); +function from(raw: string, type?: T): T { + const data = fromMeshData(deserializeDatum(raw)); return type ? castFrom(data, type) : (data as T); } +/// Casts a raw params array against a Data.Tuple schema and converts the result into Mesh's Data +/// type, ready to pass directly to Mesh's applyParamsToScript. +export function toMeshParams(params: Exact, type: T): MeshData[] { + const cast = castTo(params, type); + if (!(cast instanceof Array)) { + throw new Error('toMeshParams expects a Data.Tuple schema.'); + } + return cast.map(toMeshData); +} + /** * Note Constr cannot be used here.\ * Strings prefixed with '0x' are not UTF-8 encoded. @@ -328,7 +320,7 @@ function from(raw: Datum | Redeemer, type?: T): T { function fromJson(json: Json): Data { function toData(json: Json): Data { if (typeof json === 'string') { - return json.startsWith('0x') ? toHex(fromHex(json.slice(2))) : fromText(json); + return json.startsWith('0x') ? bytesToHex(hexToBytes(json.slice(2))) : stringToHex(json); } if (typeof json === 'number') return BigInt(json); if (typeof json === 'bigint') return json; @@ -360,9 +352,9 @@ function toJson(plutusData: Data): Json { } if (typeof data === 'string') { try { - return new TextDecoder(undefined, { fatal: true }).decode(fromHex(data)); + return hexToString(data); } catch (_) { - return '0x' + toHex(fromHex(data)); + return '0x' + data; } } if (data instanceof Array) return data.map((v) => fromData(v)); diff --git a/offchain/src/genesis.test.ts b/offchain/src/genesis.test.ts index 86ad108..56e6027 100644 --- a/offchain/src/genesis.test.ts +++ b/offchain/src/genesis.test.ts @@ -2,17 +2,18 @@ import { expect, test } from 'vitest'; import { TEST_COLLECTION_INFO } from './fixtures.test'; import { GenesisTxBuilder } from './genesis'; -import { applyTx, createEmulatorLucid } from './support.test'; +import { applyTx, createEmulatorContext } from './support.test'; test('Minimal minting policy genesis transaction', async () => { - const { lucid, seedUtxo } = await createEmulatorLucid(); - const { tx, cache } = await GenesisTxBuilder.create(lucid) + const { mesh, seedUtxo } = await createEmulatorContext(); + const { tx, cache } = await GenesisTxBuilder.create(mesh) .info({ name: 'No Constraints', nsfw: false }) .seed(seedUtxo) + .contractsUrl('local') .build(); // Check the state is on the datum as expected - const { state, info } = await applyTx(lucid, tx, cache); + const { state, info } = await applyTx(mesh, tx, cache); expect(state.info.group).toBeUndefined(); expect(state.info.mintWindow).toBeUndefined(); expect(state.info.maxNfts).toBeUndefined(); @@ -24,13 +25,13 @@ test('Minimal minting policy genesis transaction', async () => { }); test('All configuration genesis transaction', async () => { - const { lucid, seedUtxo, accounts } = await createEmulatorLucid(); + const { mesh, seedUtxo, accounts } = await createEmulatorContext(); // Use different address than selected wallet (account 0) const ownerAddress = accounts[1].address; const royaltyTokenAddress = accounts[3].address; const endMs = Date.now() + 1_000_000; const groupPolicyId = 'de2340edc45629456bf695200e8ea32f948a653b21ada10bc6f0c554'; - const { tx, cache } = await GenesisTxBuilder.create(lucid) + const { tx, cache } = await GenesisTxBuilder.create(mesh) .seed(seedUtxo) .group(groupPolicyId) .maxNfts(1) @@ -41,10 +42,11 @@ test('All configuration genesis transaction', async () => { .info(TEST_COLLECTION_INFO) .useCip88(true) .royalty(accounts[0].address, 4.3) + .contractsUrl('local') .build(); // Check the state is on the datum as expected - const { state, info } = await applyTx(lucid, tx, cache); + const { state, info } = await applyTx(mesh, tx, cache); expect(info.name === TEST_COLLECTION_INFO.name); expect(info.artist === TEST_COLLECTION_INFO.artist); expect(info.description === TEST_COLLECTION_INFO.description); @@ -54,40 +56,40 @@ test('All configuration genesis transaction', async () => { }); test('Builder errors during build', async () => { - const { lucid } = await createEmulatorLucid(); + const { mesh } = await createEmulatorContext(); // End time <= Start Time expect(() => { - GenesisTxBuilder.create(lucid).mintWindow(0, 0); + GenesisTxBuilder.create(mesh).mintWindow(0, 0); }).toThrowError(); // Invalid policy id expect(() => { - GenesisTxBuilder.create(lucid).group('This is not a 28 byte hex string'); + GenesisTxBuilder.create(mesh).group('This is not a 28 byte hex string'); }).toThrowError(); // Same address twice expect(() => { - GenesisTxBuilder.create(lucid).royalty('addr1superlegit', 10).royalty('addr1superlegit', 10); + GenesisTxBuilder.create(mesh).royalty('addr1superlegit', 10).royalty('addr1superlegit', 10); }).toThrowError(); // Greater than 100% expect(() => { - GenesisTxBuilder.create(lucid).royalty('addr1superlegitA', 60).royalty('addr1superlegitB', 60); + GenesisTxBuilder.create(mesh).royalty('addr1superlegitA', 60).royalty('addr1superlegitB', 60); }).toThrowError(); // Less that 0.1% expect(() => { - GenesisTxBuilder.create(lucid).royalty('addr1superlegit', 0.01); + GenesisTxBuilder.create(mesh).royalty('addr1superlegit', 0.01); }).toThrowError(); // Invalid bech32 royalty address (well more invalid) expect(() => { - GenesisTxBuilder.create(lucid).royalty('badnotsuperlegit', 0); + GenesisTxBuilder.create(mesh).royalty('badnotsuperlegit', 0); }).toThrowError(); // Invalid bech32 recipient expect(() => { - GenesisTxBuilder.create(lucid).ownerAddress('badnotsuperlegit'); + GenesisTxBuilder.create(mesh).ownerAddress('badnotsuperlegit'); }).toThrowError(); }); diff --git a/offchain/src/genesis.ts b/offchain/src/genesis.ts index 60e64c8..b676d71 100644 --- a/offchain/src/genesis.ts +++ b/offchain/src/genesis.ts @@ -1,4 +1,4 @@ -import { type Address, type Lucid, type Script, type UTxO } from 'lucid-cardano'; +import { serializePlutusScript, type MeshTxBuilder, type PlutusScript, type UTxO } from '@meshsdk/core'; import { addCip88MetadataToTransaction, Cip88ExtraConfig } from './cip-88'; import { addCip102RoyaltyToTransaction } from './cip-102'; @@ -13,12 +13,13 @@ import { import { MintRedeemerShape } from './contract'; import CONTRACTS_URL from './contracts-url'; import { Data } from './data'; +import { networkId, newTxBuilder, type MeshContext } from './mesh'; import { Royalty } from './royalty'; import { fetchContracts, ScriptCache } from './script'; import { checkPolicyId } from './utils'; export class GenesisTxBuilder { - #lucid: Lucid; + #mesh: MeshContext; #cache?: ScriptCache; #seed?: UTxO; #info?: CollectionInfo; @@ -39,18 +40,18 @@ export class GenesisTxBuilder { #delegate = ''; #nftValidator: 'permissive' | 'immutable' | 'custom' = 'immutable'; #royalties: Record = {}; - #royaltyValidatorAddress?: Address; + #royaltyValidatorAddress?: string; #ownerAddress?: string; - private constructor(lucid: Lucid) { - this.#lucid = lucid; + private constructor(mesh: MeshContext) { + this.#mesh = mesh; } seed(seed: UTxO) { this.#seed = seed; this.#state.info.seed = { - hash: seed.txHash, - index: seed.outputIndex, + hash: seed.input.txHash, + index: seed.input.outputIndex, }; return this; } @@ -106,9 +107,9 @@ export class GenesisTxBuilder { throw new Error('reference token validator address must be bech32 encoded string'); } - nftValidator(validator: Script) { + nftValidator(validator: PlutusScript) { this.#nftValidator = 'custom'; - this.#state.info.nftValidatorAddress = this.#lucid.utils.validatorToAddress(validator); + this.#state.info.nftValidatorAddress = serializePlutusScript(validator, undefined, networkId(this.#mesh)).address; return this; } @@ -138,8 +139,8 @@ export class GenesisTxBuilder { throw new Error('royalty address must be bech32 encoded string'); } - royaltyValidator(validator: Script) { - this.#royaltyValidatorAddress = this.#lucid.utils.validatorToAddress(validator); + royaltyValidator(validator: PlutusScript) { + this.#royaltyValidatorAddress = serializePlutusScript(validator, undefined, networkId(this.#mesh)).address; return this; } @@ -194,7 +195,7 @@ export class GenesisTxBuilder { return this; } - async build() { + async build(): Promise<{ cache: ScriptCache; tx: MeshTxBuilder; state: CollectionState; recipient: string }> { if (!this.#seed) { throw new Error('Missing required field seed. Did you forget to call `seed(utxo)`?'); } @@ -203,22 +204,26 @@ export class GenesisTxBuilder { throw new Error('Missing required collection information. Did you call `info(info)`?'); } + if (!this.#mesh.wallet) { + throw new Error('Must have a wallet selected to build a transaction.'); + } + // Create a script cache from seed utxo so that after build it can be reused if needed const cache = this.#cache ? this.#cache - : ScriptCache.cold(this.#lucid, await fetchContracts(this.#state.info.contractsUrl), this.#seed); + : ScriptCache.cold(this.#mesh, await fetchContracts(this.#state.info.contractsUrl), { + txHash: this.#seed.input.txHash, + outputIndex: this.#seed.input.outputIndex, + }); const mintScript = cache.mint(); const stateScript = cache.state(); const infoScript = cache.immutableInfo(); const spendLock = cache.spendLock(); const unit = cache.unit(); - const recipient = this.#ownerAddress ? this.#ownerAddress : await this.#lucid.wallet.address(); - - // Declare minimum managment token assets here may add royalties depending on builder config - const stateValidatorAssets = { [unit.state]: 1n }; - const infoValidatorAssets = { [unit.info]: 1n }; - const recipientAssets = { [unit.owner]: 1n }; - const assets = { ...stateValidatorAssets, ...infoValidatorAssets, ...recipientAssets }; + const recipient = this.#ownerAddress ? this.#ownerAddress : await this.#mesh.wallet?.getChangeAddress(); + if (!recipient) { + throw new Error('Must either call ownerAddress(address) or have a wallet selected.'); + } const redeemer = Data.to( { @@ -231,15 +236,24 @@ export class GenesisTxBuilder { ); // Start building tx - const tx = this.#lucid.newTx().attachMintingPolicy(mintScript.script).collectFrom([this.#seed]); + const tx = newTxBuilder(this.#mesh); + tx.changeAddress(await this.#mesh.wallet.getChangeAddress()); + tx.txIn(this.#seed.input.txHash, this.#seed.input.outputIndex, this.#seed.output.amount, this.#seed.output.address); + + // Mint the three management tokens under the seed-parameterized minting policy + for (const mintUnit of [unit.state, unit.info, unit.owner]) { + const assetName = mintUnit.slice(mintScript.policyId.length); + tx.mintPlutusScript(mintScript.script.version) + .mint('1', mintScript.policyId, assetName) + .mintingScript(mintScript.script.code) + .mintRedeemerValue(redeemer, 'CBOR'); + } // Add royalties to minted assets, if they are included const royalties = Object.values(this.#royalties); if (royalties.length > 0) { - const royaltyAddress = this.#royaltyValidatorAddress - ? this.#royaltyValidatorAddress - : await spendLock.address - addCip102RoyaltyToTransaction(tx, mintScript.policyId, royaltyAddress, royalties, redeemer); + const royaltyAddress = this.#royaltyValidatorAddress ? this.#royaltyValidatorAddress : spendLock.address; + addCip102RoyaltyToTransaction(tx, mintScript, royaltyAddress, royalties, redeemer); } // Add CIP-88 metadata to transaction if flag is set @@ -249,7 +263,7 @@ export class GenesisTxBuilder { cip102Royalties: royalties, }; - addCip88MetadataToTransaction(this.#lucid, tx, mintScript.script, unit.owner, config); + addCip88MetadataToTransaction(this.#mesh, tx, mintScript.script, unit.owner, config); } // Set the address to send the reference half of the token to @@ -272,28 +286,17 @@ export class GenesisTxBuilder { const infoData = asChainCollectionInfo(this.#info); const infoDatum = Data.to(infoData, CollectionInfoMetadataShape); - tx.mintAssets(assets, redeemer) - .payToAddressWithData( - stateScript.address, - { - inline: genesisStateDatum, - }, - stateValidatorAssets - ) - .payToAddressWithData( - infoScript.address, - { - inline: infoDatum, - }, - infoValidatorAssets - ) - .payToAddress(recipient, recipientAssets); + tx.txOut(stateScript.address, [{ unit: unit.state, quantity: '1' }]) + .txOutInlineDatumValue(genesisStateDatum, 'CBOR') + .txOut(infoScript.address, [{ unit: unit.info, quantity: '1' }]) + .txOutInlineDatumValue(infoDatum, 'CBOR') + .txOut(recipient, [{ unit: unit.owner, quantity: '1' }]); - const state = toCollectionState(this.#lucid, genesisStateData); + const state = toCollectionState(genesisStateData, networkId(this.#mesh)); return { cache, tx, state, recipient }; } - static create(lucid: Lucid) { - return new GenesisTxBuilder(lucid); + static create(mesh: MeshContext) { + return new GenesisTxBuilder(mesh); } } diff --git a/offchain/src/index.ts b/offchain/src/index.ts index 8863c93..a7e7770 100644 --- a/offchain/src/index.ts +++ b/offchain/src/index.ts @@ -8,6 +8,7 @@ export * from './common'; export * from './contract'; export * from './genesis'; export * from './image'; +export * from './mesh'; export * from './mint'; export * from './nft'; export * from './royalty'; diff --git a/offchain/src/mesh.ts b/offchain/src/mesh.ts new file mode 100644 index 0000000..1332784 --- /dev/null +++ b/offchain/src/mesh.ts @@ -0,0 +1,79 @@ +/// Lucid bundled a provider, wallet, and network together on a single `Lucid` instance that was +/// threaded through the whole library. Mesh splits those into separate concerns, so this module +/// provides a small composite standing in for that single instance across the rest of the package. +import { + MeshTxBuilder, + SLOT_CONFIG_NETWORK, + unixTimeToEnclosingSlot, + type IEvaluator, + type IFetcher, + type ISubmitter, + type Network, + type UTxO, +} from '@meshsdk/core'; + +export type MeshProvider = IFetcher & ISubmitter & IEvaluator; + +/// Minimal surface shared by BrowserWallet and MeshWallet that this library relies on. +export type MeshSignerWallet = { + getUtxos(): Promise; + getChangeAddress(): Promise; + getCollateral(): Promise; + getRewardAddresses(): Promise; + getUsedAddresses(): Promise; + signTx(unsignedTx: string, partialSign?: boolean): Promise; + signData(payload: string, address?: string): Promise<{ signature: string; key: string }>; + submitTx(tx: string): Promise; +}; + +export type MeshContext = { + provider: MeshProvider; + wallet?: MeshSignerWallet; + network: Network; +}; + +/// 0 for any testnet, 1 for mainnet -- the numeric form several address/script serialization functions expect. +export function networkId(mesh: MeshContext): 0 | 1 { + return mesh.network === 'mainnet' ? 1 : 0; +} + +export function newTxBuilder(mesh: MeshContext) { + return new MeshTxBuilder({ fetcher: mesh.provider, submitter: mesh.provider, evaluator: mesh.provider }); +} + +/// Converts a unix timestamp in milliseconds into the slot number for the context's network. +export function unixTimeToSlot(mesh: MeshContext, unixTimeMs: number): number { + return unixTimeToEnclosingSlot(unixTimeMs, SLOT_CONFIG_NETWORK[mesh.network]); +} + +/// Finds the (assumed unique) UTxO currently holding an asset unit, searching the whole chain rather +/// than a known address. Mirrors lucid-cardano's `lucid.utxoByUnit`, which Mesh has no direct equivalent +/// for -- it resolves the holder address first, then fetches the UTxO at that address. +export async function fetchUtxoByUnit(provider: MeshProvider, unit: string): Promise { + const holders = await provider.fetchAssetAddresses(unit); + if (holders.length === 0) { + return undefined; + } + + const utxos = await provider.fetchAddressUTxOs(holders[0].address, unit); + return utxos[0]; +} + +/// Polls until a transaction is visible to the provider, mirroring lucid-cardano's `lucid.awaitTx`. +export async function awaitTx( + provider: MeshProvider, + txHash: string, + checkIntervalMs = 3000, + maxAttempts = 40 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await provider.fetchTxInfo(txHash); + return true; + } catch { + await new Promise((resolve) => setTimeout(resolve, checkIntervalMs)); + } + } + + return false; +} diff --git a/offchain/src/mint.test.ts b/offchain/src/mint.test.ts index c16f3bc..56fe0dc 100644 --- a/offchain/src/mint.test.ts +++ b/offchain/src/mint.test.ts @@ -5,17 +5,24 @@ import { GenesisTxBuilder } from './genesis'; import { MintTxBuilder } from './mint'; import { fetchNftReferenceUtxos, toNftData } from './nft'; import { fetchContracts, ScriptCache } from './script'; -import { applyScriptRefTx, applyTx, createEmulatorLucid, generateNft } from './support.test'; +import { applyScriptRefTx, applyTx, createEmulatorContext, generateNft } from './support.test'; export async function genesis() { - const { lucid, emulator, seedUtxo, accounts } = await createEmulatorLucid(); + const { mesh, emulator, seedUtxo, accounts } = await createEmulatorContext(); + + if (!mesh.wallet) { + throw new Error('Test context must have a wallet selected'); + } // Use different address than selected wallet (account 0) const endMs = Date.now() + 10_000_000; const groupPolicyId = 'de2340edc45629456bf695200e8ea32f948a653b21ada10bc6f0c554'; const contracts = await fetchContracts('local'); - const cache = ScriptCache.cold(lucid, contracts, seedUtxo); - const { tx } = await GenesisTxBuilder.create(lucid) + const cache = ScriptCache.cold(mesh, contracts, { + txHash: seedUtxo.input.txHash, + outputIndex: seedUtxo.input.outputIndex, + }); + const { tx } = await GenesisTxBuilder.create(mesh) .seed(seedUtxo) .group(groupPolicyId) .cache(cache) @@ -23,19 +30,19 @@ export async function genesis() { .mintWindow(0, endMs) .useImmutableNftValidator() .royaltyValidatorAddress(cache.spendLock().address) - .ownerAddress(await lucid.wallet.address()) + .ownerAddress(await mesh.wallet.getChangeAddress()) .info(TEST_COLLECTION_INFO) .useCip88(true) .royalty(accounts[0].address, 4.3) .build(); - const { ownerUtxo, stateUtxo, state } = await applyTx(lucid, tx, cache); + const { ownerUtxo, stateUtxo, state } = await applyTx(mesh, tx, cache); - const { mintScriptReferenceUtxo, stateScriptReferenceUtxo } = await applyScriptRefTx(lucid, cache); + const { mintScriptReferenceUtxo, stateScriptReferenceUtxo } = await applyScriptRefTx(mesh, cache); return { cache, - lucid, + mesh, emulator, accounts, stateUtxo, @@ -49,8 +56,7 @@ export async function genesis() { test('Mint a token', async () => { const { cache, - lucid, - emulator, + mesh, stateUtxo, state: genesisState, mintScriptReferenceUtxo, @@ -68,11 +74,11 @@ test('Mint a token', async () => { nfts.push(generateNft()); } - const { tx } = await MintTxBuilder.create(lucid) + const { tx } = await MintTxBuilder.create(mesh) .cache(cache) .stateUtxo(stateUtxo) - .validFrom(emulator.now()) - .validTo(emulator.now() + 10_000) + .validFrom(Date.now()) + .validTo(Date.now() + 10_000) .mintingPolicyReferenceUtxo(mintScriptReferenceUtxo) .stateValidatorReferenceUtxo(stateScriptReferenceUtxo) .state(genesisState) @@ -80,14 +86,14 @@ test('Mint a token', async () => { .nfts(nfts) .build(); - const { state } = await applyTx(lucid, tx, cache); + const { state } = await applyTx(mesh, tx, cache); expect(state.nfts).toEqual(NUM_MINTS); expect(state.nextSequence).toEqual(NUM_MINTS); - const utxos = await fetchNftReferenceUtxos(lucid, cache.mint().policyId, cache.immutableNft().address); + const utxos = await fetchNftReferenceUtxos(mesh, cache.mint().policyId, cache.immutableNft().address); expect(utxos.length === NUM_MINTS); - const chainNft = await toNftData(lucid, utxos[0]); + const chainNft = await toNftData(utxos[0]); const orignalNft = nfts.find((nft) => nft.name === chainNft.name); expect(orignalNft).toEqual(chainNft); diff --git a/offchain/src/mint.ts b/offchain/src/mint.ts index 6eab9b2..c72f24f 100644 --- a/offchain/src/mint.ts +++ b/offchain/src/mint.ts @@ -1,4 +1,4 @@ -import { type Address, type Assets, type Lucid, type UTxO } from 'lucid-cardano'; +import type { MeshTxBuilder, UTxO } from '@meshsdk/core'; import { NftMetadataWrappedShape } from './cip-68'; import { toRoyaltyUnit } from './cip-102'; @@ -18,6 +18,7 @@ import { StateValidatorRedeemerShape, } from './contract'; import { Data } from './data'; +import { fetchUtxoByUnit, networkId, newTxBuilder, unixTimeToSlot, type MeshContext } from './mesh'; import { AddressedNft, MintunNft, prepareAssets } from './nft'; import { fetchStateUtxo, ScriptCache } from './script'; @@ -25,10 +26,10 @@ import { fetchStateUtxo, ScriptCache } from './script'; export const CIP_25_METADATA_LABEL = 721; export class MintTxBuilder { - #lucid: Lucid; + #mesh: MeshContext; #mintingPolicyId?: string; #cache?: ScriptCache; - #recipient?: Address; + #recipient?: string; #stateUtxo?: UTxO; #mintingPolicyReferenceUtxo?: UTxO; #stateValidatorReferenceUtxo?: UTxO; @@ -38,8 +39,8 @@ export class MintTxBuilder { #validTo?: number; #useCip25 = false; - private constructor(lucid: Lucid) { - this.#lucid = lucid; + private constructor(mesh: MeshContext) { + this.#mesh = mesh; } mintingPolicyId(mintingPolicyId: string) { @@ -52,7 +53,7 @@ export class MintTxBuilder { return this; } - recipient(recipient: Address) { + recipient(recipient: string) { this.#recipient = recipient; return this; } @@ -108,19 +109,23 @@ export class MintTxBuilder { return this; } - async build() { + async build(): Promise<{ tx: MeshTxBuilder; cache: ScriptCache; state: CollectionState }> { const numMints = this.#nfts.length; if (numMints === 0) { throw new Error('Cannot build a mint transaction with no NFTs to mint'); } + if (!this.#mesh.wallet) { + throw new Error('Must have a wallet selected to build a transaction.'); + } + if (!this.#cache) { if (this.#stateUtxo) { - const { state, cache } = await ScriptCache.fromStateUtxo(this.#lucid, this.#stateUtxo); + const { state, cache } = await ScriptCache.fromStateUtxo(this.#mesh, this.#stateUtxo); this.#cache = cache; this.#currentState = state; } else if (this.#mintingPolicyId) { - const { state, cache } = await ScriptCache.fromMintPolicyId(this.#lucid, this.#mintingPolicyId); + const { state, cache } = await ScriptCache.fromMintPolicyId(this.#mesh, this.#mintingPolicyId); this.#cache = cache; this.#currentState = state; } else { @@ -145,7 +150,7 @@ export class MintTxBuilder { let hasRoyalty; try { const royaltyUnit = toRoyaltyUnit(this.#mintingPolicyId); - const royaltyFindResult = await this.#lucid.utxoByUnit(royaltyUnit); + const royaltyFindResult = await fetchUtxoByUnit(this.#mesh.provider, royaltyUnit); hasRoyalty = royaltyFindResult !== undefined; } catch { hasRoyalty = false; @@ -154,8 +159,11 @@ export class MintTxBuilder { // Compute the updated state const currentState = this.#currentState ? this.#currentState - : await extractCollectionState(this.#lucid, this.#stateUtxo); - const recipientAddress = this.#recipient || (await this.#lucid.wallet.address()); + : await extractCollectionState(this.#stateUtxo, networkId(this.#mesh)); + const recipientAddress = this.#recipient || (await this.#mesh.wallet?.getChangeAddress()); + if (!recipientAddress) { + throw new Error('Must either call recipient(address) or have a wallet selected.'); + } const nftValidatorAddress = currentState.info.nftValidatorAddress; // Get script references if the state indicates there is a policy @@ -188,63 +196,73 @@ export class MintTxBuilder { const validatorRedeemer = Data.to('EndpointMint', StateValidatorRedeemerShape); const ownerUnit = toOwnerUnit(this.#mintingPolicyId); - const ownerAsset = { [ownerUnit]: 1n }; const stateUnit = toStateUnit(this.#mintingPolicyId); - const stateAsset = { [stateUnit]: 1n }; - const stateOutput = { inline: Data.to(chainState, CollectionStateMetadataShape) }; + const stateOutputDatum = Data.to(chainState, CollectionStateMetadataShape); + + const tx = newTxBuilder(this.#mesh); + tx.changeAddress(await this.#mesh.wallet.getChangeAddress()); + const mintScript = this.#cache.mint(); + const stateScript = this.#cache.state(); - const tx = this.#lucid.newTx(); + // Spend the state utxo (script-guarded input holding an inline datum) + tx.txIn( + this.#stateUtxo.input.txHash, + this.#stateUtxo.input.outputIndex, + this.#stateUtxo.output.amount, + this.#stateUtxo.output.address + ).txInInlineDatumPresent(); if (this.#stateValidatorReferenceUtxo) { - tx.readFrom([this.#stateValidatorReferenceUtxo]); + tx.spendingTxInReference( + this.#stateValidatorReferenceUtxo.input.txHash, + this.#stateValidatorReferenceUtxo.input.outputIndex + ); } else { - tx.attachSpendingValidator(this.#cache.state().script); + tx.txInScript(stateScript.script.code); } + tx.txInRedeemerValue(validatorRedeemer, 'CBOR'); - tx.collectFrom([this.#stateUtxo], validatorRedeemer); + // Mint the user and reference tokens for every NFT being minted + for (const asset of [...prepared.userMints, ...prepared.referenceMints]) { + const assetName = asset.unit.slice(this.#mintingPolicyId.length); + tx.mintPlutusScript(mintScript.script.version).mint(asset.quantity, this.#mintingPolicyId, assetName); - if (this.#mintingPolicyReferenceUtxo) { - tx.readFrom([this.#mintingPolicyReferenceUtxo]); - } else { - tx.attachMintingPolicy(this.#cache.mint().script); + if (this.#mintingPolicyReferenceUtxo) { + tx.mintTxInReference( + this.#mintingPolicyReferenceUtxo.input.txHash, + this.#mintingPolicyReferenceUtxo.input.outputIndex + ); + } else { + tx.mintingScript(mintScript.script.code); + } + tx.mintRedeemerValue(mintRedeemer, 'CBOR'); } // Force a validity range but allow override to keep from creating infinite validity transaction - if (this.#validFrom) { - tx.validFrom(this.#validFrom); - } else { - tx.validFrom(Date.now() - 500_000_000); - } - - if (this.#validTo) { - tx.validTo(this.#validTo); - } else { - tx.validTo(Date.now() + 600_000); - } + const validFrom = this.#validFrom ? this.#validFrom : Date.now() - 500_000_000; + const validTo = this.#validTo ? this.#validTo : Date.now() + 600_000; + tx.invalidBefore(unixTimeToSlot(this.#mesh, validFrom)).invalidHereafter(unixTimeToSlot(this.#mesh, validTo)); // TODO: Maybe enforce this just goes back to the original utxo address to prevent accidentally sending this to some other wallet - tx.mintAssets(prepared.userMints, mintRedeemer) - .mintAssets(prepared.referenceMints, mintRedeemer) - .payToAddress(recipientAddress, ownerAsset) - .payToAddressWithData(this.#cache.state().address, stateOutput, stateAsset); + tx.txOut(recipientAddress, [{ unit: ownerUnit, quantity: '1' }]) + .txOut(stateScript.address, [{ unit: stateUnit, quantity: '1' }]) + .txOutInlineDatumValue(stateOutputDatum, 'CBOR'); for (const payout of prepared.referencePayouts) { // TODO: Add flag so can choose to inline or not - const outputData = { inline: Data.to(payout.chainData, NftMetadataWrappedShape) }; - const referenceaAsset = { [payout.unit]: 1n }; - tx.payToAddressWithData(payout.address, outputData, referenceaAsset); + const outputDatum = Data.to(payout.chainData, NftMetadataWrappedShape); + tx.txOut(payout.address, [{ unit: payout.unit, quantity: '1' }]).txOutInlineDatumValue(outputDatum, 'CBOR'); } for (const [address, units] of Object.entries(prepared.userPayouts)) { - const assets: Assets = {}; - for (const unit of units) { - assets[unit] = 1n; - } - tx.payToAddress(address, assets); + tx.txOut( + address, + units.map((unit) => ({ unit, quantity: '1' })) + ); } if (this.#useCip25) { - tx.attachMetadata(CIP_25_METADATA_LABEL, prepared.cip25Metadata); + tx.metadataValue(CIP_25_METADATA_LABEL, prepared.cip25Metadata); } // Clear the state utxos because it will be spent when this transaction is submitted @@ -256,7 +274,7 @@ export class MintTxBuilder { return { tx, cache: this.#cache, state: nextState }; } - static create(lucid: Lucid) { - return new MintTxBuilder(lucid); + static create(mesh: MeshContext) { + return new MintTxBuilder(mesh); } } diff --git a/offchain/src/nft.ts b/offchain/src/nft.ts index 983f331..06b682d 100644 --- a/offchain/src/nft.ts +++ b/offchain/src/nft.ts @@ -1,4 +1,4 @@ -import { fromText, Lucid, toLabel, UTxO, type Address, type Assets } from 'lucid-cardano'; +import { stringToHex, type Asset, type UTxO } from '@meshsdk/core'; import { createReferenceData, @@ -12,7 +12,8 @@ import { COLLECTION_TOKEN_PURPOSE, toNftReferenceAssetName, toNftUserAssetName, import { TxMetadataPrimitive } from './common'; import { Data } from './data'; import { IMAGE_PURPOSE, ImageDimension, ImagePurpose } from './image'; -import { chunk, removeEmpty } from './utils'; +import type { MeshContext } from './mesh'; +import { chunk, removeEmpty, toLabel } from './utils'; // Expands on CIP-25/68 File with dimension and purpose fields type MintunFile = { @@ -59,9 +60,9 @@ type UserPayouts = { type Cip25Metadata = Record; type PreparedAssets = { - userMints: Assets; + userMints: Asset[]; userPayouts: UserPayouts; - referenceMints: Assets; + referenceMints: Asset[]; referencePayouts: ReferencePayout[]; cip25Metadata: Cip25Metadata; }; @@ -144,12 +145,12 @@ function asChainNftData(nft: MintunNft) { return Data.castFrom(Data.fromJson(metadata), NftMetadataShape); } -export async function fetchNftReferenceUtxos(lucid: Lucid, policyId: string, address: string) { - const addressUtxos = await lucid.utxosAt(address); +export async function fetchNftReferenceUtxos(mesh: MeshContext, policyId: string, address: string) { + const addressUtxos = await mesh.provider.fetchAddressUTxOs(address); const unit = `${policyId}${toLabel(REFERENCE_TOKEN_LABEL)}${toPurposeHex(COLLECTION_TOKEN_PURPOSE.NFT)}`; const utxos = []; for (const utxo of addressUtxos) { - if (Object.keys(utxo.assets).find((asset) => asset.startsWith(unit))) { + if (utxo.output.amount.find((asset) => asset.unit.startsWith(unit))) { utxos.push(utxo); } } @@ -157,8 +158,12 @@ export async function fetchNftReferenceUtxos(lucid: Lucid, policyId: string, add return utxos; } -export async function toNftData(lucid: Lucid, utxo: UTxO): Promise { - const { metadata } = await lucid.datumOf(utxo, NftMetadataWrappedShape); +export async function toNftData(utxo: UTxO): Promise { + if (!utxo.output.plutusData) { + throw new Error('UTxO does not have an inline datum to decode NFT metadata from.'); + } + + const { metadata } = Data.from(utxo.output.plutusData, NftMetadataWrappedShape); const nft = Data.toJson(metadata) as MintunChainNft; const description = Array.isArray(nft.description) ? nft.description.join('') : undefined; const files = nft.files ? nft.files.map((file) => ({ ...file, src: file.src.join('') })) : []; @@ -171,19 +176,19 @@ export function prepareAssets( nfts: AddressedNft[], policyId: string, sequence: number, - defaultRecipientAddress: Address, + defaultRecipientAddress: string, hasRoyalty: boolean, - nftValidatorAddress?: Address + nftValidatorAddress?: string ): PreparedAssets { - const userMints: Assets = {}; + const userMints: Asset[] = []; const userPayouts: UserPayouts = {}; - const referenceMints: Assets = {}; + const referenceMints: Asset[] = []; const cip25Metadata: Cip25Metadata = {}; const referencePayouts: ReferencePayout[] = []; let extra: Data = ''; if (hasRoyalty) { extra = new Map(); - extra.set(fromText('royalty_included'), 1n); + extra.set(stringToHex('royalty_included'), 1n); } for (const nft of nfts) { @@ -198,11 +203,11 @@ export function prepareAssets( const referencePayoutAddress = nftValidatorAddress ? nftValidatorAddress : recipientAdress; const userPayout = userPayouts[recipientAdress] || []; - userMints[userUnit] = 1n; + userMints.push({ unit: userUnit, quantity: '1' }); userPayout.push(userUnit); userPayouts[recipientAdress] = userPayout; - referenceMints[referenceUnit] = 1n; + referenceMints.push({ unit: referenceUnit, quantity: '1' }); referencePayouts.push({ unit: referenceUnit, address: referencePayoutAddress, chainData }); cip25Metadata[userAssetName] = metadata; diff --git a/offchain/src/script.ts b/offchain/src/script.ts index 2ed6e97..6fa1a7a 100644 --- a/offchain/src/script.ts +++ b/offchain/src/script.ts @@ -1,7 +1,7 @@ /// Allow passing in as little or as much already computed data as possible /// This cache will be updated if a some part is missing. The only case where -import { applyDoubleCborEncoding, Lucid, UTxO, type Credential, type Script } from 'lucid-cardano'; +import { applyCborEncoding, resolveScriptHash, serializePlutusScript, type PlutusScript, type UTxO } from '@meshsdk/core'; import { toInfoUnit, toOwnerUnit } from './collection'; import { extractCollectionState, toStateUnit } from './collection-state'; @@ -15,6 +15,7 @@ import { parameterizeSpendLockValidator, parameterizeStateValidator, } from './contract'; +import { fetchUtxoByUnit, networkId, type MeshContext } from './mesh'; import { findUtxo, TxReference } from './utils'; /// A loose definition of the contracts json structure. @@ -35,9 +36,8 @@ export type Contracts = { /// All the parts commonly used when dealing with a paramaterized script export type ScriptInfo = { name: string; - script: Script; + script: PlutusScript; policyId: string; - credential: Credential; address: string; }; @@ -83,21 +83,19 @@ export function getScript(contracts: Contracts, title: string) { } /// Utility function for grabbing commonly needed information about a validator (or minting policy) -export function getScriptInfo(lucid: Lucid, name: string, paramaterizedScript: string): ScriptInfo { - const script: Script = { - type: 'PlutusV2', - script: applyDoubleCborEncoding(paramaterizedScript), +export function getScriptInfo(mesh: MeshContext, name: string, paramaterizedScript: string): ScriptInfo { + const script: PlutusScript = { + version: 'V2', + code: applyCborEncoding(paramaterizedScript), }; - const policyId = lucid.utils.validatorToScriptHash(script); - const credential = lucid.utils.scriptHashToCredential(policyId); - const address = lucid.utils.credentialToAddress(credential); + const policyId = resolveScriptHash(script.code, script.version); + const address = serializePlutusScript(script, undefined, networkId(mesh)).address; return { name, script, policyId, - credential, address, }; } @@ -112,7 +110,7 @@ export type ScriptCacheWarmer = { /// Cache holds all the computed data from a builder transaction to /// allow subsequent transactions to reuse computed data export class ScriptCache { - #lucid: Lucid; + #mesh: MeshContext; #contracts: Contracts; #seed: TxReference; #mint?: ScriptInfo; @@ -126,18 +124,18 @@ export class ScriptCache { #delegateMint?: ScriptInfo; #unit?: ManageUnitLookup; - private constructor(lucid: Lucid, contracts: Contracts, seed: TxReference) { - this.#lucid = lucid; + private constructor(mesh: MeshContext, contracts: Contracts, seed: TxReference) { + this.#mesh = mesh; this.#seed = seed; this.#contracts = contracts; } - static cold(lucid: Lucid, contracts: Contracts, seed: TxReference) { - return new ScriptCache(lucid, contracts, seed); + static cold(mesh: MeshContext, contracts: Contracts, seed: TxReference) { + return new ScriptCache(mesh, contracts, seed); } - static warm(lucid: Lucid, seed: TxReference, warmer: ScriptCacheWarmer) { - const cache = new ScriptCache(lucid, warmer.contracts, seed); + static warm(mesh: MeshContext, seed: TxReference, warmer: ScriptCacheWarmer) { + const cache = new ScriptCache(mesh, warmer.contracts, seed); cache.#mint = warmer.mint; cache.#state = warmer.state; cache.#unit = warmer.unit; @@ -146,19 +144,22 @@ export class ScriptCache { } // Utility for when you already know the minting policy id - static async fromMintPolicyId(lucid: Lucid, policyId: string) { - const stateUtxo = await lucid.utxoByUnit(toStateUnit(policyId)); + static async fromMintPolicyId(mesh: MeshContext, policyId: string) { + const stateUtxo = await fetchUtxoByUnit(mesh.provider, toStateUnit(policyId)); + if (!stateUtxo) { + throw new Error(`Could not find state utxo for minting policy id: ${policyId}`); + } - return ScriptCache.fromStateUtxo(lucid, stateUtxo); + return ScriptCache.fromStateUtxo(mesh, stateUtxo); } // Utility for when you already have a state utxo - static async fromStateUtxo(lucid: Lucid, stateUtxo: UTxO) { - const state = await extractCollectionState(lucid, stateUtxo); + static async fromStateUtxo(mesh: MeshContext, stateUtxo: UTxO) { + const state = await extractCollectionState(stateUtxo, networkId(mesh)); const contracts = await fetchContracts(state.info.contractsUrl); - const cache = ScriptCache.cold(lucid, contracts, { + const cache = ScriptCache.cold(mesh, contracts, { txHash: state.info.seed.hash, outputIndex: state.info.seed.index, }); @@ -166,8 +167,8 @@ export class ScriptCache { return { state, cache }; } - static copy(lucid: Lucid, cache: ScriptCache) { - const copy = new ScriptCache(lucid, cache.#contracts, cache.#seed); + static copy(mesh: MeshContext, cache: ScriptCache) { + const copy = new ScriptCache(mesh, cache.#contracts, cache.#seed); copy.#mint = cache.#mint; copy.#state = cache.#state; copy.#unit = cache.#unit; @@ -175,8 +176,8 @@ export class ScriptCache { return copy; } - lucid() { - return this.#lucid; + mesh() { + return this.#mesh; } contractsUrl() { @@ -186,7 +187,7 @@ export class ScriptCache { mint() { if (!this.#mint) { const { txHash, outputIndex } = this.#seed; - this.#mint = parameterizeMintingPolicy(this.#lucid, this.#contracts, txHash, outputIndex); + this.#mint = parameterizeMintingPolicy(this.#mesh, this.#contracts, txHash, outputIndex); } return this.#mint; @@ -195,7 +196,7 @@ export class ScriptCache { spendLock() { if (!this.#spendLock) { const mint = this.mint(); - this.#spendLock = parameterizeSpendLockValidator(this.#lucid, this.#contracts, mint.policyId); + this.#spendLock = parameterizeSpendLockValidator(this.#mesh, this.#contracts, mint.policyId); } return this.#spendLock; @@ -204,7 +205,7 @@ export class ScriptCache { state() { if (!this.#state) { const mint = this.mint(); - this.#state = parameterizeStateValidator(this.#lucid, this.#contracts, mint.policyId); + this.#state = parameterizeStateValidator(this.#mesh, this.#contracts, mint.policyId); } return this.#state; @@ -214,7 +215,7 @@ export class ScriptCache { immutableInfo() { if (!this.#immutableInfo) { const mint = this.mint(); - this.#immutableInfo = parameterizeImmutableInfoValidator(this.#lucid, this.#contracts, mint.policyId); + this.#immutableInfo = parameterizeImmutableInfoValidator(this.#mesh, this.#contracts, mint.policyId); } return this.#immutableInfo; @@ -223,7 +224,7 @@ export class ScriptCache { immutableNft() { if (!this.#immutableNft) { const mint = this.mint(); - this.#immutableNft = parameterizeImmutableNftValidator(this.#lucid, this.#contracts, mint.policyId); + this.#immutableNft = parameterizeImmutableNftValidator(this.#mesh, this.#contracts, mint.policyId); } return this.#immutableNft; @@ -232,7 +233,7 @@ export class ScriptCache { permissiveNft() { if (!this.#permissiveNft) { const mint = this.mint(); - this.#permissiveNft = parameterizePermissiveNftValidator(this.#lucid, this.#contracts, mint.policyId); + this.#permissiveNft = parameterizePermissiveNftValidator(this.#mesh, this.#contracts, mint.policyId); } return this.#permissiveNft; @@ -241,7 +242,7 @@ export class ScriptCache { derivativeMint() { if (!this.#derivativeMint) { const mint = this.mint(); - this.#derivativeMint = parameterizeDerivativeMintingPolicy(this.#lucid, this.#contracts, mint.policyId); + this.#derivativeMint = parameterizeDerivativeMintingPolicy(this.#mesh, this.#contracts, mint.policyId); } return this.#derivativeMint; @@ -251,7 +252,7 @@ export class ScriptCache { if (!this.#delegateMint || this.#delegate !== delegate) { const mint = this.mint(); this.#delegate = delegate; - this.#delegateMint = parameterizeDelegativeMintingPolicy(this.#lucid, this.#contracts, mint.policyId, delegate); + this.#delegateMint = parameterizeDelegativeMintingPolicy(this.#mesh, this.#contracts, mint.policyId, delegate); } return this.#delegateMint; @@ -271,24 +272,23 @@ export class ScriptCache { } } -export async function fetchUtxo(lucid: Lucid, address: string, unit: string) { - const utxos = await lucid.utxosAt(address); - const utxo = utxos.find((utxo) => utxo.assets[unit]); - return utxo; +export async function fetchUtxo(mesh: MeshContext, address: string, unit: string) { + const utxos = await mesh.provider.fetchAddressUTxOs(address, unit); + return utxos[0]; } /// Fetches the UTxO that holds the state token export async function fetchStateUtxo(cache: ScriptCache) { - return await fetchUtxo(cache.lucid(), cache.state().address, cache.unit().state); + return await fetchUtxo(cache.mesh(), cache.state().address, cache.unit().state); } /// Fetches the UTxO that holds the collection info token export async function fetchInfoUtxo(cache: ScriptCache) { - return await fetchUtxo(cache.lucid(), cache.immutableInfo().address, cache.unit().info); + return await fetchUtxo(cache.mesh(), cache.immutableInfo().address, cache.unit().info); } /// Fetches the UTxO that holds the collection owner token. export async function fetchOwnerUtxo(cache: ScriptCache) { const unit = cache.unit(); - return await findUtxo(cache.lucid(), unit.owner); + return await findUtxo(cache.mesh(), unit.owner); } diff --git a/offchain/src/support.test.ts b/offchain/src/support.test.ts index 1a8799f..ed85915 100644 --- a/offchain/src/support.test.ts +++ b/offchain/src/support.test.ts @@ -1,4 +1,5 @@ -import { Emulator, generateSeedPhrase, type Assets, type Lucid, type Tx } from 'lucid-cardano'; +import { generateMnemonic, MeshWallet, SLOT_CONFIG_NETWORK, type Asset, type MeshTxBuilder, type UTxO } from '@meshsdk/core'; +import { ScalusEmulator } from '@meshsdk/scalus-emulator'; import { expect } from 'vitest'; import { extractCollectionInfo } from './collection-info'; @@ -9,59 +10,85 @@ import { fetchMintingPolicyReferenceUtxo, fetchStateValidatorReferenceUtxo, } from './contract'; +import { awaitTx, networkId, type MeshContext } from './mesh'; import { MintunNft } from './nft'; import { fetchInfoUtxo, fetchOwnerUtxo, fetchStateUtxo, ScriptCache } from './script'; import { submit } from './utils'; -/// Creates a new emulator account with the given assets, if any. -export async function generateEmulatorAccount(lucid: Lucid, assets: Assets = {}) { - const seedPhrase = generateSeedPhrase(); +const TEST_NETWORK = 'preview'; + +let seedCounter = 0; +/// Emulator seed utxos need distinct, real-looking (64 hex char) tx hashes. +function fakeTxHash(): string { + seedCounter += 1; + return seedCounter.toString(16).padStart(64, '0'); +} - lucid.selectWalletFromSeed(seedPhrase); - const address = await lucid.wallet.address(); +/// Derives a fresh emulator account (mnemonic + address) without a fetcher/submitter attached yet -- +/// mirrors lucid-cardano's ability to derive an address from a seed phrase with no provider. +async function deriveEmulatorAccount() { + const words = generateMnemonic().split(' '); + const wallet = new MeshWallet({ networkId: 0, key: { type: 'mnemonic', words } }); + await wallet.init(); + const address = await wallet.getChangeAddress(); + return { words, address }; +} +/// Creates a new emulator account with the given assets, if any. +export async function generateEmulatorAccount(assets: Asset[] = []) { + const { words, address } = await deriveEmulatorAccount(); return { - seedPhrase, + words, address, assets, }; } -/// Instantiates an instace of Lucid with an Emulator as the provider. The emulator is seeded with two starting accounts with one of them preselected. -export async function createEmulatorLucid() { - const { Lucid } = await import('lucid-cardano'); +/// Instantiates a MeshContext backed by the Scalus emulator. The emulator is seeded with two starting +/// accounts with one of them preselected as the context's wallet. +export async function createEmulatorContext() { + const ACCOUNT_0 = await generateEmulatorAccount([{ unit: 'lovelace', quantity: '7500000000000' }]); + const ACCOUNT_1 = await generateEmulatorAccount([{ unit: 'lovelace', quantity: '100000000' }]); + const ACCOUNT_2 = await generateEmulatorAccount([{ unit: 'lovelace', quantity: '10000000' }]); + const ACCOUNT_3 = await generateEmulatorAccount([{ unit: 'lovelace', quantity: '10000000' }]); + + const initialUtxos: UTxO[] = [ACCOUNT_0, ACCOUNT_1].map((account) => ({ + input: { txHash: fakeTxHash(), outputIndex: 0 }, + output: { address: account.address, amount: account.assets }, + })); - const customLucid = await Lucid.new(undefined, 'Custom'); + const emulator = new ScalusEmulator(initialUtxos, SLOT_CONFIG_NETWORK[TEST_NETWORK]); - const ACCOUNT_0 = await generateEmulatorAccount(customLucid, { lovelace: 7_500_0000_000n }); - const ACCOUNT_1 = await generateEmulatorAccount(customLucid, { lovelace: 100_000_000n }); - const ACCOUNT_2 = await generateEmulatorAccount(customLucid, { lovelace: 10_000_000n }); - const ACCOUNT_3 = await generateEmulatorAccount(customLucid, { lovelace: 10_000_000n }); + const wallet = new MeshWallet({ + networkId: 0, + fetcher: emulator, + submitter: emulator, + key: { type: 'mnemonic', words: ACCOUNT_0.words }, + }); + await wallet.init(); - const emulator = new Emulator([ACCOUNT_0, ACCOUNT_1]); - const lucid = await Lucid.new(emulator); - lucid.selectWalletFromSeed(ACCOUNT_0.seedPhrase); + const mesh: MeshContext = { provider: emulator, wallet, network: TEST_NETWORK }; - const utxos = await lucid.wallet.getUtxos(); + const utxos = await wallet.getUtxos(); expect(utxos.length > 0, 'Wallet must have at least one UTXO for this test'); const seedUtxo = utxos[0]; return { - lucid, + mesh, emulator, accounts: [ACCOUNT_0, ACCOUNT_1, ACCOUNT_2, ACCOUNT_3], seedUtxo, }; } -export async function applyScriptRefTx(lucid: Lucid, cache: ScriptCache) { - const mintReferenceTx = await createMintingPolicyReference(lucid, cache); - const mintReferenceTxHash = await submit(mintReferenceTx); - await lucid.awaitTx(mintReferenceTxHash); +export async function applyScriptRefTx(mesh: MeshContext, cache: ScriptCache) { + const mintReferenceTx = await createMintingPolicyReference(mesh, cache); + const mintReferenceTxHash = await submit(mesh, mintReferenceTx); + await awaitTx(mesh.provider, mintReferenceTxHash); - const stateReferenceTx = await createStateValidatorReference(lucid, cache); - const stateReferenceTxHash = await submit(stateReferenceTx); - await lucid.awaitTx(stateReferenceTxHash); + const stateReferenceTx = await createStateValidatorReference(mesh, cache); + const stateReferenceTxHash = await submit(mesh, stateReferenceTx); + await awaitTx(mesh.provider, stateReferenceTxHash); // Use cache utils to check to tokens were distributed as expected const mintScriptReferenceUtxo = await fetchMintingPolicyReferenceUtxo(cache); @@ -80,9 +107,9 @@ export async function applyScriptRefTx(lucid: Lucid, cache: ScriptCache) { }; } -export async function applyTx(lucid: Lucid, tx: Tx, cache: ScriptCache) { - const txHash = await submit(tx); - await lucid.awaitTx(txHash); +export async function applyTx(mesh: MeshContext, tx: MeshTxBuilder, cache: ScriptCache) { + const txHash = await submit(mesh, tx); + await awaitTx(mesh.provider, txHash); // Use cache utils to check to tokens were distributed as expected const stateUtxo = await fetchStateUtxo(cache); @@ -96,8 +123,8 @@ export async function applyTx(lucid: Lucid, tx: Tx, cache: ScriptCache) { // NOTE: Typescript won't detect that the vitest expect will terminate the // function early so we use ! to manually indicate that is the case - const state = await extractCollectionState(lucid, stateUtxo!); - const info = await extractCollectionInfo(lucid, infoUtxo!); + const state = await extractCollectionState(stateUtxo!, networkId(mesh)); + const info = await extractCollectionInfo(infoUtxo!); return { txHash, stateUtxo: stateUtxo!, ownerUtxo: ownerUtxo!, infoUtxo: infoUtxo!, state, info }; } diff --git a/offchain/src/utils.ts b/offchain/src/utils.ts index 8834f5f..d022690 100644 --- a/offchain/src/utils.ts +++ b/offchain/src/utils.ts @@ -1,4 +1,6 @@ -import { fromText, toText, type Lucid, type Tx, type Unit, type UTxO } from 'lucid-cardano'; +import { hexToBytes, hexToString, stringToHex, type MeshTxBuilder, type UTxO } from '@meshsdk/core'; + +import { fetchUtxoByUnit, type MeshContext } from './mesh'; /// Simple form of UTxO that only includes part needed to reference a transaction export type TxReference = { @@ -7,32 +9,39 @@ export type TxReference = { }; /// Utility function for completing a transaction, signing it, and then submitting it. -export async function submit(tx: Tx) { - const completed = await tx.complete(); - const signed = await completed.sign().complete(); - return await signed.submit(); +export async function submit(mesh: MeshContext, builder: MeshTxBuilder) { + if (!mesh.wallet) { + throw new Error('No wallet selected.'); + } + + // Lucid derived the change address from the connected wallet automatically; Mesh requires it be + // set explicitly before completing the transaction so it can run UTxO selection. + builder.changeAddress(await mesh.wallet.getChangeAddress()); + const unsignedTx = await builder.complete(); + const signedTx = await mesh.wallet.signTx(unsignedTx); + return await mesh.wallet.submitTx(signedTx); } /// Result of trying to find a UTxO. -/// wallet is false if the UTxO was not found in the selected lucid wallet. +/// wallet is false if the UTxO was not found in the selected wallet. export type UtxoFindResult = { utxo?: UTxO; wallet: boolean; }; /// Utility function to find a utxo first in wallet then in general if no match was found -export async function findUtxo(lucid: Lucid, unit: Unit): Promise { +export async function findUtxo(mesh: MeshContext, unit: string): Promise { // Normally should be in the wallet let utxo: UTxO | undefined = undefined; let wallet = false; - if (lucid.wallet) { - const utxos = await lucid.wallet.getUtxos(); - utxo = utxos.find((utxo) => utxo.assets[unit]); + if (mesh.wallet) { + const utxos = await mesh.wallet.getUtxos(); + utxo = utxos.find((utxo) => utxo.output.amount.some((asset) => asset.unit === unit)); } // Wider net search but can't be spent from current wallet if (!utxo) { - utxo = await lucid.utxoByUnit(unit); + utxo = await fetchUtxoByUnit(mesh.provider, unit); } else { wallet = true; } @@ -62,7 +71,7 @@ export function chunk(str: string, charactersPerChunk = 64, prefix = ''): string /// Utility function for encoding and splitting a UTF8 string into 64 byte chunks. export function asChunkedHex(utf8String: string, prefix = ''): string[] { - const hex = fromText(utf8String); + const hex = stringToHex(utf8String); const charactersPerChunk = 128; return chunk(hex, charactersPerChunk, prefix); } @@ -70,9 +79,9 @@ export function asChunkedHex(utf8String: string, prefix = ''): string[] { /// Utility function for resting a chunked hex string to a single UTF8 string export function toJoinedText(hexStrings: string | string[]) { if (Array.isArray(hexStrings)) { - return toText(hexStrings.join('')); + return hexToString(hexStrings.join('')); } else { - return toText(hexStrings); + return hexToString(hexStrings); } } @@ -108,3 +117,75 @@ export function removeEmpty(record: Record | unknown[]) { } } } + +/// CIP-67 asset name label helpers. lucid-cardano shipped these; Mesh does not, so reimplemented here +/// against the CIP-67 spec (CRC-8/SMBUS checksum of the 2-byte label, framed as `0