diff --git a/src/@types/Datatoken.ts b/src/@types/Datatoken.ts index 31c8b09f..16c09f7c 100644 --- a/src/@types/Datatoken.ts +++ b/src/@types/Datatoken.ts @@ -10,6 +10,10 @@ export interface DatatokenCreateParams { cap: string name?: string symbol?: string + filesObject?: any // file object for template 4 + accessListFactory?: string // access list factory address + allowAccessList?: string // Allow List Contract (if any) + denyAccessList?: string // Deny List Contract (if any) } export interface ConsumeMarketFee { diff --git a/src/config/Config.ts b/src/config/Config.ts index 5b5b3eeb..8a33bc79 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -179,4 +179,8 @@ export class Config { DFRewards?: string DFStrategyV1?: string veFeeEstimate?: string + + // is confidential evm + confidentialEVM?: boolean + accessListFactory?: string } diff --git a/src/config/ConfigHelper.ts b/src/config/ConfigHelper.ts index 7449062d..546592c4 100644 --- a/src/config/ConfigHelper.ts +++ b/src/config/ConfigHelper.ts @@ -178,6 +178,11 @@ export const configHelperNetworks: Config[] = [ } ] +export const KNOWN_CONFIDENTIAL_EVMS = [ + 23294, // oasis_sapphire + 23295 // oasis_sapphire_testnet +] + export class ConfigHelper { /* Load contract addresses from env ADDRESS_FILE (generated by ocean-contracts) */ public getAddressesFromEnv(network: string, customAddresses?: any): Partial { @@ -200,7 +205,8 @@ export class ConfigHelper { veDelegationProxy, DFRewards, DFStrategyV1, - veFeeEstimate + veFeeEstimate, + AccessListFactory } = customAddresses[network] configAddresses = { nftFactoryAddress: ERC721Factory, @@ -218,6 +224,7 @@ export class ConfigHelper { DFRewards, DFStrategyV1, veFeeEstimate, + accessListFactory: AccessListFactory, ...(process.env.AQUARIUS_URL && { metadataCacheUri: process.env.AQUARIUS_URL }), ...(process.env.PROVIDER_URL && { providerUri: process.env.PROVIDER_URL }) } @@ -239,7 +246,8 @@ export class ConfigHelper { veDelegationProxy, DFRewards, DFStrategyV1, - veFeeEstimate + veFeeEstimate, + AccessListFactory } = DefaultContractsAddresses[network] configAddresses = { nftFactoryAddress: ERC721Factory, @@ -257,6 +265,7 @@ export class ConfigHelper { DFRewards, DFStrategyV1, veFeeEstimate, + accessListFactory: AccessListFactory, ...(process.env.AQUARIUS_URL && { metadataCacheUri: process.env.AQUARIUS_URL }), ...(process.env.PROVIDER_URL && { providerUri: process.env.PROVIDER_URL }) } @@ -273,6 +282,7 @@ export class ConfigHelper { */ public getConfig(network: string | number, infuraProjectId?: string): Config { const filterBy = typeof network === 'string' ? 'network' : 'chainId' + let config = configHelperNetworks.find((c) => c[filterBy] === network) if (!config) { @@ -292,7 +302,23 @@ export class ConfigHelper { console.log(e) addresses = null } - const contractAddressesConfig = this.getAddressesFromEnv(config.network, addresses) + + let contractAddressesConfig = this.getAddressesFromEnv(config.network, addresses) + // check oasis network name typos on addresses.json + if (!contractAddressesConfig && KNOWN_CONFIDENTIAL_EVMS.includes(config.chainId)) { + contractAddressesConfig = this.getAddressesFromEnv( + config.network.replace('sapph', 'saph'), + addresses + ) + } + config.confidentialEVM = + filterBy === 'chainId' + ? KNOWN_CONFIDENTIAL_EVMS.includes(Number(network)) + : network.toString().includes('oasis_sap') + if (config.confidentialEVM) { + config.accessListFactory = contractAddressesConfig.accessListFactory + } + config = { ...config, ...contractAddressesConfig } const nodeUri = infuraProjectId diff --git a/src/contracts/NFT.ts b/src/contracts/NFT.ts index a951192a..4fb0afae 100644 --- a/src/contracts/NFT.ts +++ b/src/contracts/NFT.ts @@ -12,7 +12,7 @@ import { SmartContract } from './SmartContract' import { calculateActiveTemplateIndex, getOceanArtifactsAdressesByChainId -} from '../utils/Asset' +} from '../utils/Assets' export class Nft extends SmartContract { getDefaultAbi() { diff --git a/src/contracts/NFTFactory.ts b/src/contracts/NFTFactory.ts index dc095158..40f60e79 100644 --- a/src/contracts/NFTFactory.ts +++ b/src/contracts/NFTFactory.ts @@ -1,4 +1,4 @@ -import { BigNumber } from 'ethers' +import { BigNumber, ethers } from 'ethers' import ERC721Factory from '@oceanprotocol/contracts/artifacts/contracts/ERC721Factory.sol/ERC721Factory.json' import { generateDtName, @@ -580,20 +580,39 @@ export class NftFactory extends SmartContractWithAddress { } } + // common stuff for other templates + const addresses = [ + dtParams.minter, + dtParams.paymentCollector, + dtParams.mpFeeAddress, + dtParams.feeToken + ] + + if (dtParams.filesObject) { + // template 4 only, ignored for others + if (dtParams.accessListFactory) { + addresses.push(dtParams.accessListFactory) + } + if (dtParams.allowAccessList) { + addresses.push(dtParams.allowAccessList) + } + + if (dtParams.denyAccessList) { + addresses.push(dtParams.denyAccessList) + } + } + return { templateIndex: dtParams.templateIndex, strings: [dtParams.name || name, dtParams.symbol || symbol], - addresses: [ - dtParams.minter, - dtParams.paymentCollector, - dtParams.mpFeeAddress, - dtParams.feeToken - ], + addresses, uints: [ await this.amountToUnits(null, dtParams.cap, 18), await this.amountToUnits(null, dtParams.feeAmount, feeTokenDecimals) ], - bytess: [] + bytess: dtParams.filesObject + ? [ethers.utils.toUtf8Bytes(JSON.stringify(dtParams.filesObject))] + : [] } } diff --git a/src/services/Provider.ts b/src/services/Provider.ts index ae00cea1..1552e22c 100644 --- a/src/services/Provider.ts +++ b/src/services/Provider.ts @@ -510,7 +510,7 @@ export class Provider { )) + 1 ).toString() - const signature = await this.signProviderRequest(signer, did + nonce) + const signature = await this.signProviderRequest(signer, did + nonce) // did + nonce let consumeUrl = downloadUrl consumeUrl += `?fileIndex=${fileIndex}` consumeUrl += `&documentId=${did}` diff --git a/src/utils/Asset.ts b/src/utils/Asset.ts deleted file mode 100644 index 9eb8e24e..00000000 --- a/src/utils/Asset.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ethers, Signer } from 'ethers' - -import { NftFactory } from '../contracts/NFTFactory' -// eslint-disable-next-line import/no-named-default -import ERC20Template from '@oceanprotocol/contracts/artifacts/contracts/interfaces/IERC20Template.sol/IERC20Template.json' -import fs from 'fs' -// eslint-disable-next-line import/no-named-default -import { default as addrs } from '@oceanprotocol/contracts/addresses/address.json' - -/** - * Get the artifacts address from the address.json file - * either from the env or from the ocean-contracts dir - * @returns data or null - */ -export function getOceanArtifactsAdresses(): any { - try { - if (process.env.ADDRESS_FILE) { - // eslint-disable-next-line security/detect-non-literal-fs-filename - const data = fs.readFileSync(process.env.ADDRESS_FILE, 'utf8') - return JSON.parse(data) - } - return addrs - } catch (error) { - return addrs - } -} - -export function getOceanArtifactsAdressesByChainId(chain: number): any { - try { - // eslint-disable-next-line security/detect-non-literal-fs-filename - const data = getOceanArtifactsAdresses() - if (data) { - const networks = Object.keys(data) - for (const network of networks) { - if (data[network].chainId === chain) { - return data[network] - } - } - } - } catch (error) { - console.error(error) - } - return null -} - -/** - * Use this function to accurately calculate the template index, and also checking if the template is active - * @param owner the signer account - * @param nftContractAddress the nft contract address, usually artifactsAddresses.ERC721Factory - * @param template the template ID or template address (from smart contract getId() function) - * @returns index of the template on the list - */ -export async function calculateActiveTemplateIndex( - owner: Signer, - nftContractAddress: string, // addresses.ERC721Factory, - template: string | number -): Promise { - // is an ID number? - const isTemplateID = typeof template === 'number' - - const factoryERC721 = new NftFactory(nftContractAddress, owner) - const currentTokenCount = await factoryERC721.getCurrentTokenTemplateCount() - for (let i = 1; i <= currentTokenCount; i++) { - const tokenTemplate = await factoryERC721.getTokenTemplate(i) - - const erc20Template = new ethers.Contract( - tokenTemplate.templateAddress, - ERC20Template.abi, - owner - ) - - // check for ID - if (isTemplateID) { - const id = await erc20Template.connect(owner).getId() - if (tokenTemplate.isActive && id.toString() === template.toString()) { - return i - } - } else if ( - tokenTemplate.isActive && - tokenTemplate.templateAddress === template.toString() - ) { - return i - } - } - // if nothing is found it returns -1 - return -1 -} diff --git a/src/utils/Assets.ts b/src/utils/Assets.ts new file mode 100644 index 00000000..652137a0 --- /dev/null +++ b/src/utils/Assets.ts @@ -0,0 +1,350 @@ +import { SHA256 } from 'crypto-js' +import { ethers, Signer } from 'ethers' +import { ConfigHelper } from '../../src/config' +import { hexlify } from 'ethers/lib/utils' +import { createHash } from 'crypto' +import fs from 'fs' + +// eslint-disable-next-line import/no-named-default +import { default as Addresses } from '@oceanprotocol/contracts/addresses/address.json' +import { Aquarius } from '../services/Aquarius' +import { NftFactory } from '../contracts/NFTFactory' +import { Nft } from '../contracts/NFT' +import { DatatokenCreateParams } from '../@types/Datatoken' +import { NftCreateData } from '../@types/NFTFactory' +import { ZERO_ADDRESS } from './Constants' +import { DispenserCreationParams } from '../@types/Dispenser' +import { FreCreationParams } from '../@types/FixedPrice' +import { getEventFromTx } from './ContractUtils' +import { ProviderInstance } from '../services/Provider' +// eslint-disable-next-line import/no-named-default +import ERC20Template from '@oceanprotocol/contracts/artifacts/contracts/interfaces/IERC20Template.sol/IERC20Template.json' +import AccessListFactory from '@oceanprotocol/contracts/artifacts/contracts/accesslists/AccessListFactory.sol/AccessListFactory.json' +import ERC20Template4 from '@oceanprotocol/contracts/artifacts/contracts/templates/ERC20Template4.sol/ERC20Template4.json' + +// import * as hre from 'hardhat' + +export const DEVELOPMENT_CHAIN_ID = 8996 +// template address OR templateId +export function isConfidentialEVM(network: string | number): boolean { + const config = new ConfigHelper().getConfig(network) + return config && config.confidentialEVM +} + +/** + * Get the artifacts address from the address.json file + * either from the env or from the ocean-contracts dir + * @returns data or null + */ +export function getOceanArtifactsAdresses(): any { + try { + if (process.env.ADDRESS_FILE) { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const data = fs.readFileSync(process.env.ADDRESS_FILE, 'utf8') + return JSON.parse(data) + } + return Addresses + } catch (error) { + return Addresses + } +} + +/** + * Get the artifacts address from the address.json file, for the given chain + * either from the env or from the ocean-contracts dir, safer than above, because sometimes the network name + * is mispeled, best example "optimism_sepolia" vs "optimism-sepolia" + * @returns data or null + */ +export function getOceanArtifactsAdressesByChainId(chain: number): any { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const data = getOceanArtifactsAdresses() + if (data) { + const networks = Object.keys(data) + for (const network of networks) { + if (data[network].chainId === chain) { + return data[network] + } + } + } + } catch (error) { + console.error(error) + } + return null +} + +/** + * Use this function to accurately calculate the template index, and also checking if the template is active + * @param owner the signer account + * @param nftContractAddress the nft contract address, usually artifactsAddresses.ERC721Factory + * @param template the template ID or template address (from smart contract getId() function) + * @returns index of the template on the list + */ +export async function calculateActiveTemplateIndex( + owner: Signer, + nftContractAddress: string, // addresses.ERC721Factory, + template: string | number +): Promise { + // is an ID number? + const isTemplateID = typeof template === 'number' + + const factoryERC721 = new NftFactory(nftContractAddress, owner) + const currentTokenCount = await factoryERC721.getCurrentTokenTemplateCount() + for (let i = 1; i <= currentTokenCount; i++) { + const tokenTemplate = await factoryERC721.getTokenTemplate(i) + + const erc20Template = new ethers.Contract( + tokenTemplate.templateAddress, + ERC20Template.abi, + owner + ) + + // check for ID + if (isTemplateID) { + const id = await erc20Template.connect(owner).getId() + if (tokenTemplate.isActive && id.toString() === template.toString()) { + return i + } + } else if ( + tokenTemplate.isActive && + tokenTemplate.templateAddress === template.toString() + ) { + return i + } + } + // if nothing is found it returns -1 + return -1 +} +/** + * + * @param name asset name + * @param symbol asse symbol + * @param owner owner address + * @param assetUrl asset url, if present and confidential evm, add it to token create params + * @param templateIDorAddress either template address or id + * @param ddo ddo + * @param encryptDDO encrypt or not? + * @param providerUrl the provider URL + * @param providerFeeToken the provider fee token + * @param aquariusInstance aquarius, could be node instance url + * @param allowAccessList?: string, + * @param denyAccessList?: string + * @returns ddo id as string + */ +export async function createAsset( + name: string, + symbol: string, + owner: Signer, + assetUrl: any, // files object + templateIDorAddress: string | number, // If string, it's template address , otherwise, it's templateId + ddo: any, + encryptDDO: boolean = true, // default is true + providerUrl: string, + providerFeeToken: string, + aquariusInstance: Aquarius, + accessListFactory?: string, // access list factory address + allowAccessList?: string, // allow list address + denyAccessList?: string // deny list address +): Promise { + const isAddress = typeof templateIDorAddress === 'string' + const isTemplateIndex = typeof templateIDorAddress === 'number' + if (!isAddress && !isTemplateIndex) { + throw new Error('Invalid template! Must be a "number" or a "string"') + } + const chainID = (await owner.provider.getNetwork()).chainId + + const config = new ConfigHelper().getConfig(parseInt(String(chainID))) + + let templateIndex = await calculateActiveTemplateIndex( + owner, + config.nftFactoryAddress, + templateIDorAddress + ) + + if (templateIndex < 1) { + // for testing purposes only + if (chainID === DEVELOPMENT_CHAIN_ID) { + templateIndex = 1 + } else throw new Error(`Invalid template index: ${templateIndex}`) + } + + const nft = new Nft(owner, chainID) + + const nftFactory = new NftFactory(config.nftFactoryAddress, owner) + + // get nft owner + const account = await owner.getAddress() + + // from hex to number format + ddo.chainId = parseInt(chainID.toString(10)) + const nftParamsAsset: NftCreateData = { + name, + symbol, + templateIndex: 1, + tokenURI: 'aaa', + transferable: true, + owner: account + } + const datatokenParams: DatatokenCreateParams = { + templateIndex, + cap: '100000', + feeAmount: '0', + paymentCollector: account, + feeToken: providerFeeToken || config.oceanTokenAddress, + minter: account, + mpFeeAddress: ZERO_ADDRESS + } + + // include fileObject in the DT constructor + if (config.confidentialEVM) { + datatokenParams.filesObject = assetUrl + datatokenParams.accessListFactory = accessListFactory || config.accessListFactory + datatokenParams.allowAccessList = allowAccessList + datatokenParams.denyAccessList = denyAccessList + } + + let bundleNFT + try { + if (!ddo.stats?.price?.value) { + bundleNFT = await nftFactory.createNftWithDatatoken(nftParamsAsset, datatokenParams) + } else if (ddo.stats?.price?.value === '0') { + const dispenserParams: DispenserCreationParams = { + dispenserAddress: config.dispenserAddress, + maxTokens: '1', + maxBalance: '100000000', + withMint: true, + allowedSwapper: ZERO_ADDRESS + } + bundleNFT = await nftFactory.createNftWithDatatokenWithDispenser( + nftParamsAsset, + datatokenParams, + dispenserParams + ) + } else { + // fixed price + const fixedPriceParams: FreCreationParams = { + fixedRateAddress: config.fixedRateExchangeAddress, + baseTokenAddress: config.oceanTokenAddress, + owner: account, + marketFeeCollector: account, + baseTokenDecimals: 18, + datatokenDecimals: 18, + fixedRate: ddo.stats.price.value, + marketFee: '0', + allowedConsumer: account, + withMint: true + } + bundleNFT = await nftFactory.createNftWithDatatokenWithFixedRate( + nftParamsAsset, + datatokenParams, + fixedPriceParams + ) + } + } catch (err) { + console.log('ERROR creating NFT bundle', err) + return null + } + + const trxReceipt = await bundleNFT.wait() + // events have been emitted + const nftCreatedEvent = getEventFromTx(trxReceipt, 'NFTCreated') + const tokenCreatedEvent = getEventFromTx(trxReceipt, 'TokenCreated') + + const nftAddress = nftCreatedEvent.args.newTokenAddress + const datatokenAddressAsset = tokenCreatedEvent.args.newTokenAddress + // create the files encrypted string + assetUrl.datatokenAddress = datatokenAddressAsset + assetUrl.nftAddress = nftAddress + + if (config.confidentialEVM) { + // we need to update files object on the SC otherwise it will fail validation on provider + // because DDO datatokenAddress and nftAddress will not match the values on files object + const contract = new ethers.Contract(datatokenAddressAsset, ERC20Template4.abi, owner) + try { + const tx = await contract.setFilesObject( + ethers.utils.toUtf8Bytes(JSON.stringify(assetUrl)) + ) + if (tx.wait) { + await tx.wait() + } + } catch (err) { + console.log('Error updating files object with data token and nft addresses: ', err) + return null + } + } + + // if confidential EVM no need to make encrypt call here + if (config.confidentialEVM) { + ddo.services[0].files = '' // on confidental EVM it needs to be empty string not null, for schema validation + } else { + ddo.services[0].files = await ProviderInstance.encrypt(assetUrl, chainID, providerUrl) + } + + ddo.services[0].datatokenAddress = datatokenAddressAsset + ddo.services[0].serviceEndpoint = providerUrl + + ddo.nftAddress = nftAddress + ddo.id = 'did:op:' + SHA256(ethers.utils.getAddress(nftAddress) + chainID.toString(10)) + + let metadata + let metadataHash + let flags + if (encryptDDO) { + metadata = await ProviderInstance.encrypt(ddo, chainID, providerUrl) + const validateResult = await aquariusInstance.validate(ddo) + metadataHash = validateResult.hash + flags = 2 + } else { + const stringDDO = JSON.stringify(ddo) + const bytes = Buffer.from(stringDDO) + metadata = hexlify(bytes) + metadataHash = '0x' + createHash('sha256').update(metadata).digest('hex') + flags = 0 + } + + await nft.setMetadata( + nftAddress, + await owner.getAddress(), + 0, + providerUrl, + '', + ethers.utils.hexlify(flags), + metadata, + metadataHash + ) + return ddo.id +} + +/** + * deploy new access list factory if needed + * @param accessListFactory accessListFactory address + * @param owner owner account + * @param addressesList list of addresses to deploy + * @returns accessListFactory address + */ +export async function createAccessListFactory( + accessListFactory: string, + owner: Signer, + addressesList?: string[] +): Promise { + const factory = new ethers.Contract(accessListFactory, AccessListFactory.abi, owner) + const ownerAccount = await owner.getAddress() + try { + const accessListTx = await factory.deployAccessListContract( + 'AllowList', + 'ALLOW', + true, + ownerAccount, + addressesList || [ownerAccount], + ['https://oceanprotocol.com/nft/'] + ) + if (accessListTx && accessListTx.wait) { + const trxReceipt = await accessListTx.wait() + const events = getEventFromTx(trxReceipt, 'NewAccessList') + return events.args[0] + } + } catch (error) { + console.log('ERROR createAccessListFactory(): ', error) + } + return null +} diff --git a/src/utils/OrderUtils.ts b/src/utils/OrderUtils.ts index fe6b8284..69eba8e6 100644 --- a/src/utils/OrderUtils.ts +++ b/src/utils/OrderUtils.ts @@ -66,6 +66,7 @@ export async function orderAsset( ) const templateIndex = await datatoken.getId(asset.datatokens[datatokenIndex].address) + const fixedRates = await datatoken.getFixedRates( asset.datatokens[datatokenIndex].address ) @@ -137,7 +138,7 @@ export async function orderAsset( orderParams._consumeMarketFee ) } - if (templateIndex === 2) { + if (templateIndex === 2 || templateIndex === 4) { return await datatoken.buyFromDispenserAndOrder( asset.services[serviceIndex].datatokenAddress, orderParams, @@ -210,7 +211,7 @@ export async function orderAsset( orderParams._consumeMarketFee ) } - if (templateIndex === 2) { + if (templateIndex === 2 || templateIndex === 4) { const tx: any = await approve( consumerAccount, config, diff --git a/src/utils/index.ts b/src/utils/index.ts index 5e99d811..55da7cf7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './SignatureUtils' export * from './TokenUtils' export * from './ProviderErrors' export * from './OrderUtils' +export * from './Assets' diff --git a/test/integration/PublishFlows.test.ts b/test/integration/PublishFlows.test.ts index 99375691..b809e39b 100644 --- a/test/integration/PublishFlows.test.ts +++ b/test/integration/PublishFlows.test.ts @@ -22,6 +22,7 @@ import { Files } from '../../src/@types' +import { createAsset } from '../../src/utils' function delay(interval: number) { return it('should delay', (done) => { setTimeout(() => done(), interval) @@ -198,6 +199,25 @@ describe('Publish tests', async () => { delay(19000) + it('should publish a dataset with fixed price (create NFT + Datoken + fixed price) using createAsset() fn', async () => { + const fixedPriceDdo: DDO = { ...genericAsset } + const ownerAddress = publisherAccount + const asset = await createAsset( + 'test asset', + 'TEST', + ownerAddress, + assetUrl, + 1, // template 1 on dev network + fixedPriceDdo, + true, // encrypted ddo + providerUrl, + ZERO_ADDRESS, // provider fee token + aquarius + ) + + assert(asset !== null, 'Could not publish asset!') + }) + it('should resolve the fixed price dataset', async () => { const resolvedDDO = await aquarius.waitForAqua(fixedPricedDID) assert(resolvedDDO, 'Cannot fetch DDO from Aquarius') diff --git a/test/unit/AssetUtils.test.ts b/test/unit/AssetUtils.test.ts new file mode 100644 index 00000000..48f11c7d --- /dev/null +++ b/test/unit/AssetUtils.test.ts @@ -0,0 +1,47 @@ +import { assert } from 'chai' +import { KNOWN_CONFIDENTIAL_EVMS } from '../../src/config' +import { provider, getAddresses } from '../config' +import { calculateActiveTemplateIndex, isConfidentialEVM } from '../../src/utils' +import { Signer } from 'ethers/lib/ethers' + +let nftOwner: Signer +let addresses: any +describe('Asset utils (createAsset)', () => { + before(async () => { + nftOwner = (await provider.getSigner(0)) as Signer + addresses = await getAddresses() + }) + + it('should check if confidential EVM', async () => { + for (const chain of KNOWN_CONFIDENTIAL_EVMS) { + assert( + isConfidentialEVM(chain) === true, + `Chain Id: "${chain}" is not a confidental EVM` + ) + } + + // optimism sepolia + // 11155420 + assert( + isConfidentialEVM(11155420) === false, + `Chain Id: "11155420" is wrongly considered a confidental EVM` + ) + }) + + // checking if active by connecting to the smart contract as well + it('Calculate index - Should get correct template index from contract getId() (using template ID as template)', async () => { + const okTemplate = await calculateActiveTemplateIndex( + nftOwner, + addresses.ERC721Factory, + 3 + ) + assert(okTemplate === 3, 'wrong template index, should be index 3!') + + const wrongOne = await calculateActiveTemplateIndex( + nftOwner, + addresses.ERC721Factory, + 6 + ) + assert(wrongOne === -1, 'wrong template index, should only exist 3!') + }) +})