Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion offchain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -49,6 +50,6 @@
"@sinclair/typebox": "^0.32.27"
},
"peerDependencies": {
"lucid-cardano": "^0.10.10"
"@meshsdk/core": "^1.9.1"
}
}
77 changes: 39 additions & 38 deletions offchain/src/aiken.ts
Original file line number Diff line number Diff line change
@@ -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 });

Expand Down Expand Up @@ -142,7 +142,7 @@ export function toTimeWindow(interval: PosixTimeIntervalType): TimeWindow {
export function asChainMap(data: Record<string, unknown>): Map<string, Data> {
const metadata: Map<string, Data> = 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);
}
Expand All @@ -157,55 +157,56 @@ 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,
};
}

/// 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);
}
27 changes: 16 additions & 11 deletions offchain/src/cip-102.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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');
}
17 changes: 10 additions & 7 deletions offchain/src/cip-88.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand All @@ -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');
Expand Down
59 changes: 33 additions & 26 deletions offchain/src/cip-88.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -316,21 +317,26 @@ export class Cip88Builder {
return this;
}

async build(lucid: Lucid): Promise<RegistrationMetadata> {
if (!lucid.wallet) {
throw new Error('Must provide an instance of lucide with a selected wallet');
async build(mesh: MeshContext): Promise<RegistrationMetadata> {
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]];
}
Expand All @@ -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);
Expand All @@ -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
) {
Expand All @@ -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);
}
Loading
Loading