diff --git a/packages/build-tools/template-generator/README.md b/packages/build-tools/template-generator/README.md new file mode 100644 index 000000000..81c55a637 --- /dev/null +++ b/packages/build-tools/template-generator/README.md @@ -0,0 +1,92 @@ +# Template Generator + +This tool generates a new Effectstream project based on the selected options. + +## Usage + +```sh +TEMPLATE_PATH=`pwd` deno task -f @effectstream/template-generator start +cd my-project +chmod +x patch.sh +deno install --allow-scripts && ./patch.sh +deno task build:evm +deno task build:midnight +# deno task -f @[project-name]/database pgtyped:update +# deno task -f @[project-name]/midnight-contracts midnight-contract:deploy + + +# deno task build:cardano +# deno task build:avail +# deno task build:bitcoin +deno task dev +``` + +## Test +```sh +TEMPLATE_CONFIG_FILE_ALL=true +or +TEMPLATE_CONFIG_FILE_ALL_FAST=true +``` +To skip the interactive prompt and generate the project with all options. + +## Packages (& Roadmap) + +### General +- [ ] Config Parameters +- [ ] Add links to documentation in each step +- [ ] Maybe this could also scaffold the templates? +- [ ] How do we test this? (We need a non-interactive cli, and ci-cd pipeline) +- [ ] Make this work in the /template folder +- [ ] Documentation + +### Contracts/Avail +- [ ] Create base project structure +- [ ] Add empty contract + +### Contracts/Bitcoin +- [ ] Pending on implementation. + +### Contracts/Cardano +- [ ] Pending on implementation. + +### Contracts/EVM +- [ ] Create Contract Management System +- [ ] Add Empty Contract +- [ ] Add ERC20/721/1155 contract +- [ ] Add Effectstream L2 contract +- [ ] Add Inverse* contract + +### Contracts/Midnight +Including Contracts/Midnight-Contracts + +- [ ] Create Contact Management System + +### Shared/Data-Types + +### Client/API +- [ ] Add Generic API Endpoint Reading from database + +### Client/Database +- [ ] Add Generic Example Tables + +### Client/Batcher +- [ ] Add Generic EVM Batcher Adapter +- [ ] Enable disable code depending on selected chains + +### Client/Node +- [ ] LocalhostConfig Enable Sections depending on selected chains +- [ ] LocalhostConfig Enable Dynamic code depending on selected contracts +- [ ] StateMachine create state for grammar +- [ ] Grammar create sections depending on selected contracts + +### Frontend/Standalone +- [ ] Working example frontend reading from API +- [ ] Working example including @effectstream/wallets + +### Frontend/Integrated Vite-Deno +- [ ] Working example frontend reading from API +- [ ] Working example including @effectstream/wallets + +### Root files +- [ ] Readme should contain instructions per chain/contract selected + \ No newline at end of file diff --git a/packages/build-tools/template-generator/deno.json b/packages/build-tools/template-generator/deno.json new file mode 100644 index 000000000..35174e058 --- /dev/null +++ b/packages/build-tools/template-generator/deno.json @@ -0,0 +1,42 @@ +{ + "name": "@effectstream/template-generator", + "version": "0.3.0", + "license": "MIT", + "exports": { + ".": "./mod.ts" + }, + "alias": { + "npm:/react-dom@18.3.1": "react-dom" + }, + "strict": true, + "imports": { + "@std/path": "jsr:@std/path@^1.1.3", + "@effectstream/evm-hardhat": "../../chains/evm-hardhat/deno.json", + "react": "npm:react@18.3.1", + "react-dom": "npm:react-dom@18.3.1", + "ink": "npm:ink@5.0.1", + "ink-text-input": "npm:ink-text-input@6.0.0", + "ink-select-input": "npm:ink-select-input@6.2.0", + "ink-big-text": "npm:ink-big-text@2.0.0", + "ink-gradient": "npm:ink-gradient@3.0.0", + "@types/react": "npm:@types/react@18.3.1" + }, + "tasks": { + "check": "deno check src/template-generator.tsx", + "start": "deno run -A src/template-generator.tsx" + }, + "compilerOptions": { + "types": [ + "react", + "react-dom", + "@types/react" + ], + "lib": [ + "dom", + "dom.iterable", + "deno.ns" + ], + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} diff --git a/packages/build-tools/template-generator/mod.ts b/packages/build-tools/template-generator/mod.ts new file mode 100644 index 000000000..ebadb4c23 --- /dev/null +++ b/packages/build-tools/template-generator/mod.ts @@ -0,0 +1 @@ +export * from './src/template-generator.tsx'; \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/file-operations.ts b/packages/build-tools/template-generator/src/file-operations.ts new file mode 100644 index 000000000..d27b7aa32 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-operations.ts @@ -0,0 +1,66 @@ +import * as path from "jsr:@std/path"; + +// Copy files +// sourceDir - source directory +// targetDir - target directory +// +// replacements - simple [TAG] -> `code` replacement +// codeBlocks - enable/disable /** TAG */ ... /** TAG */ +// codeInsertions - replace /** TAG */ with `code` +// replaceFileNames - x => y +export async function copyFiles( + sourceDir: string, + targetDir: string, + replacements: Record = {}, + codeBlocks: Record = {}, + codeInsertions: Record = {}, + replaceFileNames: Record = {}, +): Promise { + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(".rename", ""); + if (replaceFileNames[finalName]) { + finalName = replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(codeBlocks)) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(codeInsertions)) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(replacements)) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// // Get current directory - this has to be JSR compatible. +// export function currentDir(): string { +// return path.dirname(path.fromFileUrl(import.meta.url)); +// } diff --git a/packages/build-tools/template-generator/src/file-types/deno-json-file.ts b/packages/build-tools/template-generator/src/file-types/deno-json-file.ts new file mode 100644 index 000000000..22cd813fb --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/deno-json-file.ts @@ -0,0 +1,22 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class DenoJsonFile extends GeneratedFile { + constructor(filePath: string, private content: any) { + super(filePath); + if (!this.content.name) { + throw new Error('Name is required'); + } + if (!this.content.exports) { + this.content.exports = { + // This is a placeholder + ".": "./src/mod.ts", + }; + } + } + + getContent(): string { + return JSON.stringify(this.content, null, 2); + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/deno-json-root-file.ts b/packages/build-tools/template-generator/src/file-types/deno-json-root-file.ts new file mode 100644 index 000000000..4ce15044b --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/deno-json-root-file.ts @@ -0,0 +1,62 @@ +import { GeneratedFile } from './generated-file.ts'; +import { Chain, Frontend, PAIMA_SCOPE, EFFECTSTREAM_VERSION } from '../options.ts'; + +export class RootDenoJsonFile extends GeneratedFile { + private content: object; + + constructor(filePath: string, projectName: string, chains: Chain[], frontend: Frontend) { + super(filePath); + + const tasks: Record = {}; + const imports: Record = {}; + + // Conditional tasks + if (chains.includes('evm')) { + tasks['build:evm'] = `deno task -f @${projectName}/evm-contracts build:mod`; + } + if (chains.includes('midnight')) { + tasks['build:midnight'] = `deno task -r compact`; + } + + tasks['dev'] = `deno task -f @${projectName}/node dev`; + + if (frontend === 'intergrated-vite-deno') { + tasks['start:frontend'] = `deno task -f @${projectName}/frontend dev`; + } + + tasks['check'] = `deno task -f @${projectName}/node check`; + + // Conditional imports + imports[`${PAIMA_SCOPE}/tui`] = `jsr:${PAIMA_SCOPE}/tui@${EFFECTSTREAM_VERSION}`; + if (chains.includes('midnight')) { + imports[`${PAIMA_SCOPE}/midnight-contracts`] = `jsr:${PAIMA_SCOPE}/midnight-contracts@${EFFECTSTREAM_VERSION}`; + } + if (chains.includes('evm')) { + imports[`${PAIMA_SCOPE}/evm-contracts`] = `jsr:${PAIMA_SCOPE}/evm-contracts@${EFFECTSTREAM_VERSION}`; + } + imports['@std/path'] = 'jsr:@std/path@^1.1.3'; + + this.content = { + workspace: ['./packages/**/*'], + nodeModulesDir: 'auto', + tasks, + imports, + lint: { + rules: { + exclude: [ + 'no-this-alias', + 'require-yield', + 'no-explicit-any', + 'ban-types', + 'no-unused-vars', + 'no-slow-types', + ], + }, + }, + }; + } + + getContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/packages/build-tools/template-generator/src/file-types/generated-file.ts b/packages/build-tools/template-generator/src/file-types/generated-file.ts new file mode 100644 index 000000000..5ec3777b8 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/generated-file.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +export abstract class GeneratedFile { + constructor(public filePath: string) {} + + protected abstract getContent(): string; + + public async write(mode?: number): Promise { + const dir = path.dirname(this.filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(this.filePath, this.getContent(), { mode }); + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/gitignore-file.ts b/packages/build-tools/template-generator/src/file-types/gitignore-file.ts new file mode 100644 index 000000000..ecfbca0c6 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/gitignore-file.ts @@ -0,0 +1,36 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class GitignoreFile extends GeneratedFile { + constructor(filePath: string) { + super(filePath); + } + + getContent(): string { + return ` +**/node_modules +forge-std +logs/ + +# e2e tests +/cache +/out + +# Frontend +.vite + +# Batcher file storage +batcher-data/ +midnight-level-db/ + +# Docusaurus +.docusaurus/ + +# System files +.DS_Store + +# Temporary files +packages/client/node/tmux.conf +packages/client/node/install.sh + `.trim(); + } +} diff --git a/packages/build-tools/template-generator/src/file-types/html-file.ts b/packages/build-tools/template-generator/src/file-types/html-file.ts new file mode 100644 index 000000000..906ebbb0c --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/html-file.ts @@ -0,0 +1,21 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class HtmlFile extends GeneratedFile { + constructor(filePath: string, private title: string, private scriptSrc: string) { + super(filePath); + } + + getContent(): string { + return ` + + + ${this.title} + + + + +`; + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/markdown-file.ts b/packages/build-tools/template-generator/src/file-types/markdown-file.ts new file mode 100644 index 000000000..05784ec83 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/markdown-file.ts @@ -0,0 +1,30 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class MarkdownFile extends GeneratedFile { + protected lines: string[] = []; + + constructor(filePath: string) { + super(filePath); + } + + addHeader(text: string, level: 1 | 2 | 3 | 4 | 5 | 6 = 1): this { + this.lines.push(`${'#'.repeat(level)} ${text}`); + return this; + } + + addText(text: string): this { + this.lines.push(text); + return this; + } + + addCodeBlock(code: string, language = 'sh'): this { + this.lines.push(`\`\`\`${language}\n${code}\n\`\`\``); + return this; + } + + getContent(): string { + return this.lines.join('\n\n'); + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/package-json-file.ts b/packages/build-tools/template-generator/src/file-types/package-json-file.ts new file mode 100644 index 000000000..be7296fdb --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/package-json-file.ts @@ -0,0 +1,13 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class PackageJsonFile extends GeneratedFile { + constructor(filePath: string, private content: object) { + super(filePath); + } + + getContent(): string { + return JSON.stringify(this.content, null, 2); + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/package-json-root-file.ts b/packages/build-tools/template-generator/src/file-types/package-json-root-file.ts new file mode 100644 index 000000000..eb6b52b3f --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/package-json-root-file.ts @@ -0,0 +1,19 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class RootPackageJsonFile extends GeneratedFile { + constructor(filePath: string) { + super(filePath); + } + + getContent(): string { + const content = { + dependencies: { + "@electric-sql/pglite": "^0.3.14" + }, + devDependencies: { + "wait-on": "8.0.3" + } + }; + return JSON.stringify(content, null, 4); + } +} diff --git a/packages/build-tools/template-generator/src/file-types/patch-file.ts b/packages/build-tools/template-generator/src/file-types/patch-file.ts new file mode 100644 index 000000000..9d08df10a --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/patch-file.ts @@ -0,0 +1,154 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class PatchFile extends GeneratedFile { + constructor(filePath: string) { + super(filePath); + } + + getContent(): string { + return `#!/bin/bash + +# Apply patches +echo "🔧 Applying patches..." + +# Function to comment out a line at specific line number +comment_line() { + local file="$1" + local line_num="$2" + local comment_text="$3" + + if [[ -f "$file" ]]; then + # Use sed to comment out the line (add // at the beginning) + sed -i.bak "\${line_num}s|^|// |" "$file" + echo "✅ Commented line $line_num in $file" + else + echo "⚠️ Warning: File $file not found" + fi +} + +# Function to replace content in a file +replace_in_file() { + local file="$1" + local old_content="$2" + local new_content="$3" + + if [[ -f "$file" ]]; then + # Create backup first + cp "$file" "$file.bak" + + # Use perl for more reliable string replacement + # Only escape the search pattern, not the replacement text + perl -i -pe "s/\Q$old_content\E/$new_content/g" "$file" + echo "✅ Replaced content in $file" + else + echo "⚠️ Warning: File $file not found" + fi +} + +# Function to replace content using a temp file approach for complex strings +replace_complex_content() { + local file="$1" + local old_content="$2" + local new_content="$3" + + if [[ -f "$file" ]]; then + # Create backup first + cp "$file" "$file.bak" + + # Write old and new content to temp files + local temp_old=$(mktemp) + local temp_new=$(mktemp) + printf '%s' "$old_content" > "$temp_old" + printf '%s' "$new_content" > "$temp_new" + + # Use python for reliable string replacement + python3 -c " +import sys +with open('$file', 'r') as f: + content = f.read() +with open('$temp_old', 'r') as f: + old = f.read() +with open('$temp_new', 'r') as f: + new = f.read() +content = content.replace(old, new) +with open('$file', 'w') as f: + f.write(content) +" + + # Clean up temp files + rm "$temp_old" "$temp_new" + echo "✅ Replaced complex content in $file" + else + echo "⚠️ Warning: File $file not found" + fi +} + +# Apply Common Hardhat Patches +shopt -s nullglob # Expands to nothing if no match is found + +echo "Applying common Hardhat patches for versions 3.0.0-3.0.9..." + +# Patch hardhat compiler.js +for dir in ./node_modules/.deno/hardhat@3.[0-1]*.[0-9]*/ ; do + file_to_patch="\${dir}node_modules/hardhat/dist/src/internal/builtin-plugins/solidity/build-system/compiler/compiler.js" + echo "Commenting out await stdoutFileHandle.close() in \${file_to_patch}..." + comment_line "$file_to_patch" 48 "await stdoutFileHandle.close();" +done + +# Patch hardhat-utils fs.js +for dir in ./node_modules/.deno/@nomicfoundation+hardhat-utils@3.[0-1]*.[0-9]*/ ; do + file_to_patch="\${dir}node_modules/@nomicfoundation/hardhat-utils/dist/src/fs.js" + echo "Commenting out first await fileHandle?.close() in \${file_to_patch}..." + comment_line "$file_to_patch" 209 "await fileHandle?.close();" + echo "Commenting out second await fileHandle?.close() in \${file_to_patch}..." + comment_line "$file_to_patch" 275 "await fileHandle?.close();" +done + +shopt -u nullglob # Revert to default + +echo "✅ All patches applied successfully" + +# Apply Specific Patches +echo "Replacing fetch-blob streams.cjs content..." +replace_complex_content "./node_modules/.deno/fetch-blob@3.2.0/node_modules/fetch-blob/streams.cjs" " // \`node:stream/web\` got introduced in v16.5.0 as experimental + // and it's preferred over the polyfilled version. So we also + // suppress the warning that gets emitted by NodeJS for using it. + try { + const process = require('node:process') + const { emitWarning } = process + try { + process.emitWarning = () => {} + Object.assign(globalThis, require('node:stream/web')) + process.emitWarning = emitWarning + } catch (error) { + process.emitWarning = emitWarning + throw error + } + } catch (error) { + // fallback to polyfill implementation + Object.assign(globalThis, require('web-streams-polyfill/dist/ponyfill.es2018.js')) + }" " Object.assign(globalThis, require('web-streams-polyfill/dist/ponyfill.es2018.js'))" + +echo "Replacing fetch-blob from.js imports..." +replace_complex_content "./node_modules/.deno/fetch-blob@3.2.0/node_modules/fetch-blob/from.js" "import { statSync, createReadStream, promises as fs } from 'node:fs' +import { basename } from 'node:path' +import DOMException from 'node-domexception' + +import File from './file.js' +import Blob from './index.js' + +const { stat } = fs" "import { statSync, createReadStream } from 'node:fs' +import { basename } from 'node:path' +import DOMException from 'node-domexception' + +import File from './file.js' +import Blob from './index.js' + +import { promises as stat } from 'node:fs' +" + +echo "✅ All patches applied successfully" + + `; + } +} diff --git a/packages/build-tools/template-generator/src/file-types/readme-file.ts b/packages/build-tools/template-generator/src/file-types/readme-file.ts new file mode 100644 index 000000000..0d7a09259 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/readme-file.ts @@ -0,0 +1,30 @@ +import { MarkdownFile } from './markdown-file.ts'; +import { Chain } from '../options.ts'; + +export class MainReadmeFile extends MarkdownFile { + constructor(filePath: string, projectName: string, chains: Chain[]) { + super(filePath); + + const buildCommands = chains.map(chain => `deno task build:${chain}`).join('\n'); + const quickStartScript = ` +# Check for external dependencies +./../check.sh + +# Install packages +deno install --allow-scripts && ./patch.sh + +# Compile contracts +${buildCommands} + +# TODO this can be ran after the first launch of the node +# deno task -f @my-project-all pgtyped:update + +# Launch Paima Engine Node +deno task dev + `.trim(); + + this.addHeader(`${projectName} Quick Start`) + .addCodeBlock(quickStartScript) + .addText('Open [http://localhost:10599](http://localhost:10599)'); + } +} diff --git a/packages/build-tools/template-generator/src/file-types/solidity-file.ts b/packages/build-tools/template-generator/src/file-types/solidity-file.ts new file mode 100644 index 000000000..cce76e136 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/solidity-file.ts @@ -0,0 +1,13 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class SolidityFile extends GeneratedFile { + constructor(filePath: string, private contractName: string) { + super(filePath); + } + + getContent(): string { + return `// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.20;\n\ncontract ${this.contractName} {}\n`; + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/sql-file.ts b/packages/build-tools/template-generator/src/file-types/sql-file.ts new file mode 100644 index 000000000..13b6e6a54 --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/sql-file.ts @@ -0,0 +1,13 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class SqlFile extends GeneratedFile { + constructor(filePath: string, private content: string) { + super(filePath); + } + + getContent(): string { + return this.content; + } +} + + diff --git a/packages/build-tools/template-generator/src/file-types/typescript-file.ts b/packages/build-tools/template-generator/src/file-types/typescript-file.ts new file mode 100644 index 000000000..3fde0ba7c --- /dev/null +++ b/packages/build-tools/template-generator/src/file-types/typescript-file.ts @@ -0,0 +1,13 @@ +import { GeneratedFile } from './generated-file.ts'; + +export class TypescriptFile extends GeneratedFile { + constructor(filePath: string, private content: string) { + super(filePath); + } + + getContent(): string { + return this.content; + } +} + + diff --git a/packages/build-tools/template-generator/src/options.ts b/packages/build-tools/template-generator/src/options.ts new file mode 100644 index 000000000..561705372 --- /dev/null +++ b/packages/build-tools/template-generator/src/options.ts @@ -0,0 +1,56 @@ +export const PAIMA_SCOPE = '@paimaexample'; +export const EFFECTSTREAM_VERSION = "0.3.125"; +import { evmContractOptions } from '@effectstream/evm-hardhat/scaffold'; +import { availContractOptions } from '@effectstream/avail-contracts/scaffold'; +import { cardanoContractOptions } from '@effectstream/cardano-contracts/scaffold'; +import { midnightContractOptions } from '@effectstream/midnight-contracts/scaffold'; + +// Define types for the options +export type Chain = 'evm' | 'midnight' | 'cardano' | 'bitcoin' | 'avail'; +export const ALL_CHAINS: { label: string; value: Chain }[] = [ + { label: 'EVM', value: 'evm' }, + { label: 'Midnight', value: 'midnight' }, + { label: 'Cardano', value: 'cardano' }, + { label: 'Bitcoin', value: 'bitcoin' }, + { label: 'Avail', value: 'avail' }, +]; + +export const CONTRACTS_BY_CHAIN: Record = { + evm: evmContractOptions, + midnight: midnightContractOptions, + cardano: cardanoContractOptions, + // TODO Move to Bitcoin Contracts + bitcoin: [ { label: 'Empty Contract', value: 'empty-contract' } ], + avail: availContractOptions +}; + +type EvmContracts = typeof CONTRACTS_BY_CHAIN['evm'][number]['value']; +type MidnightContracts = typeof CONTRACTS_BY_CHAIN['midnight'][number]['value']; +type CardanoContracts = typeof CONTRACTS_BY_CHAIN['cardano'][number]['value']; +type BitcoinContracts = typeof CONTRACTS_BY_CHAIN['bitcoin'][number]['value']; +type AvailContracts = typeof CONTRACTS_BY_CHAIN['avail'][number]['value']; + +export type Contract = EvmContracts | MidnightContracts | CardanoContracts | BitcoinContracts | AvailContracts; + +export type Frontend = 'standalone-esbuild'; + +export const ALL_FRONTENDS: { label: string; value: Frontend }[] = [ + { label: 'Web Frontend', value: 'standalone-esbuild' }, +]; + +export const DEFAULT_DEV_OPTIONS = { + inMemoryDb: true, + useBatcher: true, + useExplorer: true, +}; +export type DevOptions = typeof DEFAULT_DEV_OPTIONS; + + +export type TemplateOptions = { + projectName: string; + folderPath: string; + chains: Chain[]; + contracts: Partial>; + frontends: Frontend[]; + devOptions: DevOptions; +}; diff --git a/packages/build-tools/template-generator/src/package-manager.ts b/packages/build-tools/template-generator/src/package-manager.ts new file mode 100644 index 000000000..2912d3153 --- /dev/null +++ b/packages/build-tools/template-generator/src/package-manager.ts @@ -0,0 +1,50 @@ +import { TemplateOptions } from './options.ts'; +import { ClientNodePackage } from './packages/client-node.ts'; +import { ClientDatabasePackage } from './packages/client-database.ts'; +import { ClientBatcherPackage } from './packages/client-batcher.ts'; +import { ChainCardanoPackage } from './packages/chain-cardano.ts'; +import { ChainBitcoinPackage } from './packages/chain-bitcoin.ts'; +import { ChainAvailPackage } from './packages/chain-avail.ts'; +import { ChainEVMPackage } from './packages/chain-evm.ts'; +import { ChainMidnightPackage } from './packages/chain-midnight.ts'; +import { SharedDataTypesPackage } from './packages/shared-data-types.ts'; +import { StandaloneEsbuildPackage } from './packages/standalone-esbuild.ts'; +import { PackageInfo } from './packages/abstract-package.ts'; + +export class PackageManager { + constructor(private projectPath: string, private options: TemplateOptions) {} + + public async generate(): Promise { + const createdPackages: (PackageInfo | null)[] = []; + + createdPackages.push(await new ClientNodePackage(this.projectPath, this.options).generate()); + createdPackages.push(await new ClientDatabasePackage(this.projectPath, this.options).generate()); + createdPackages.push(await new ClientBatcherPackage(this.projectPath, this.options).generate()); + createdPackages.push(await new SharedDataTypesPackage(this.projectPath, this.options).generate()); + + for (const chain of this.options.chains) { + if (chain === 'evm') { + createdPackages.push(await new ChainEVMPackage(this.projectPath, this.options, chain).generate()); + } else if (chain === 'midnight') { + const chainPackage = await new ChainMidnightPackage(this.projectPath, this.options, chain).generate(); + createdPackages.push(chainPackage); + if (chainPackage.subPackages) { + createdPackages.push(...chainPackage.subPackages); + } + } else if (chain === 'cardano') { + createdPackages.push(await new ChainCardanoPackage(this.projectPath, this.options, chain).generate()); + } else if (chain === 'bitcoin') { + createdPackages.push(await new ChainBitcoinPackage(this.projectPath, this.options, chain).generate()); + } else if (chain === 'avail') { + createdPackages.push(await new ChainAvailPackage(this.projectPath, this.options, chain).generate()); + } else { + throw new Error(`Chain ${chain} not supported`); + } + } + if (this.options.frontends.includes('standalone-esbuild')) { + createdPackages.push(await new StandaloneEsbuildPackage(this.projectPath, this.options).generate()); + } + + return createdPackages.filter((p): p is PackageInfo => p != null); + } +} diff --git a/packages/build-tools/template-generator/src/packages/abstract-package.ts b/packages/build-tools/template-generator/src/packages/abstract-package.ts new file mode 100644 index 000000000..c1e571c84 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/abstract-package.ts @@ -0,0 +1,13 @@ +import { TemplateOptions } from '../options.ts'; + +export interface PackageInfo { + name: string; + path: string; + subPackages?: PackageInfo[]; +} + +export abstract class Package { + constructor(protected projectPath: string, protected options: TemplateOptions) {} + + abstract generate(): Promise; +} diff --git a/packages/build-tools/template-generator/src/packages/chain-avail.ts b/packages/build-tools/template-generator/src/packages/chain-avail.ts new file mode 100644 index 000000000..c57da747c --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/chain-avail.ts @@ -0,0 +1,54 @@ +import path from "node:path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { Chain, EFFECTSTREAM_VERSION } from "../options.ts"; +import { scaffoldAvailProject } from "@effectstream/avail-contracts/scaffold"; +import { + availPrimitiveBlock as availPrimitiveBlock_, + availGrammar as availGrammar_, + availStateMachine as availStateMachine_, +} from "@effectstream/avail-contracts/scaffold"; + +export class ChainAvailPackage extends Package { + constructor( + projectPath: string, + options: Package["options"], + private chain: Chain + ) { + super(projectPath, options); + } + + public async generate(): Promise { + const chainPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + this.chain + "-contracts" + ); + // const contracts = this.options.contracts.evm?.map(contract => ({ + // safeCodeName: ChainEVMPackage.safeCodeContractName(contract), + // safePackageName: ChainEVMPackage.safePackageName(contract), + // })) || []; + + return await scaffoldAvailProject( + chainPath, + this.options.projectName, + EFFECTSTREAM_VERSION + ); + } + + public static availPrimitiveBlock(contract: string): string { + return availPrimitiveBlock_(contract); + } + + public static availGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; + } { + return availGrammar_(contract); + } + + public static availStateMachine(contract: string): string { + return availStateMachine_(contract); + } +} diff --git a/packages/build-tools/template-generator/src/packages/chain-bitcoin.ts b/packages/build-tools/template-generator/src/packages/chain-bitcoin.ts new file mode 100644 index 000000000..008b50970 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/chain-bitcoin.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { DenoJsonFile } from "../file-types/deno-json-file.ts"; +import { Chain, EFFECTSTREAM_VERSION } from "../options.ts"; +import { + scaffoldBitcoinProject, + bitcoinPrimitiveBlock as bitcoinPrimitiveBlock_, + bitcoinGrammar as bitcoinGrammar_, + bitcoinStateMachine as bitcoinStateMachine_, +} from "@effectstream/bitcoin-contracts/scaffold"; + +export class ChainBitcoinPackage extends Package { + constructor( + projectPath: string, + options: Package["options"], + private chain: Chain + ) { + super(projectPath, options); + } + + public async generate(): Promise { + const chainPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + this.chain + "-contracts" + ); + + return await scaffoldBitcoinProject( + chainPath, + this.options.projectName, + EFFECTSTREAM_VERSION + ); + } + + public static bitcoinImportBlock( + projectName: string, + contractName: string + ): string { + return ``; + } + + public static bitcoinGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; + } { + return bitcoinGrammar_(contract); + } + + public static bitcoinStateMachine(contract: string): string { + return bitcoinStateMachine_(contract); + } + + public static bitcoinPrimitiveBlock(_: string): string { + return bitcoinPrimitiveBlock_(); + } +} diff --git a/packages/build-tools/template-generator/src/packages/chain-cardano.ts b/packages/build-tools/template-generator/src/packages/chain-cardano.ts new file mode 100644 index 000000000..695aa7994 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/chain-cardano.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { DenoJsonFile } from "../file-types/deno-json-file.ts"; +import { Chain, EFFECTSTREAM_VERSION } from "../options.ts"; +import { scaffoldCardanoProject } from "@effectstream/cardano-contracts/scaffold"; +import { + cardanoPrimitiveBlock as cardanoPrimitiveBlock_, + cardanoGrammar as cardanoGrammar_, + cardanoStateMachine as cardanoStateMachine_, +} from "@effectstream/cardano-contracts/scaffold"; + +export class ChainCardanoPackage extends Package { + constructor( + projectPath: string, + options: Package["options"], + private chain: Chain + ) { + super(projectPath, options); + } + + public async generate(): Promise { + const chainPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + this.chain + "-contracts" + ); + + return await scaffoldCardanoProject( + chainPath, + this.options.projectName, + EFFECTSTREAM_VERSION + ); + } + + public static cardanoPrimitiveBlock(contract: string): string { + return cardanoPrimitiveBlock_(contract); + } + + public static cardanoGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; + } { + return cardanoGrammar_(contract); + } + + public static cardanoStateMachine(contract: string): string { + return cardanoStateMachine_(contract); + } +} diff --git a/packages/build-tools/template-generator/src/packages/chain-evm.ts b/packages/build-tools/template-generator/src/packages/chain-evm.ts new file mode 100644 index 000000000..b937fc239 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/chain-evm.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { Chain, EFFECTSTREAM_VERSION } from "../options.ts"; +import { scaffoldEVMProject } from "@effectstream/evm-hardhat/scaffold"; +import { + evmContractOptions, + evmPrimitiveBlock as evmPrimitiveBlock_, + evmGrammar as evmGrammar_, + evmStateMachine as evmStateMachine_, +} from "@effectstream/evm-hardhat/scaffold"; + +export class ChainEVMPackage extends Package { + constructor( + projectPath: string, + options: Package["options"], + public chain: Chain + ) { + super(projectPath, options); + } + + public static safeCodeContractName(contract: string): string { + return contract.replace(/-/g, "_"); + } + + public static safePackageName(contract: string): string { + return contract.replace(/-/g, "_"); + } + + public async generate(): Promise { + const chainPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + this.chain + "-contracts" + ); + const contracts = + this.options.contracts.evm?.map((contract) => ({ + safeCodeName: ChainEVMPackage.safeCodeContractName(contract), + safePackageName: ChainEVMPackage.safePackageName(contract), + })) || []; + + return await scaffoldEVMProject( + chainPath, + this.options.projectName, + EFFECTSTREAM_VERSION, + contracts + ); + } + + public static evmGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; + } { + return evmGrammar_(contract); + } + + public static evmStateMachine(contract: string): string { + return evmStateMachine_(contract); + } + + public static evmPrimitiveBlock( + contract: string, + contractPackageName: string + ): string { + return evmPrimitiveBlock_(contract, contractPackageName); + } +} diff --git a/packages/build-tools/template-generator/src/packages/chain-midnight.ts b/packages/build-tools/template-generator/src/packages/chain-midnight.ts new file mode 100644 index 000000000..3405ed130 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/chain-midnight.ts @@ -0,0 +1,129 @@ +import path from "node:path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { Chain, EFFECTSTREAM_VERSION } from "../options.ts"; +import { + scaffoldMidnightProject, + scaffoldMidnightContract, +} from "@effectstream/midnight-contracts/scaffold"; +import { midnightContractOptions } from "@effectstream/midnight-contracts/scaffold"; +import { + midnightPrimitiveBlock as midnightPrimitiveBlock_, + getReadContractCode as getReadContractCode_, + contractBatcher as contractBatcher_, + midnightGrammar as midnightGrammar_, + midnightStateMachine as midnightStateMachine_, +} from "@effectstream/midnight-contracts/scaffold"; + +export class ChainMidnightPackage extends Package { + constructor( + projectPath: string, + options: Package["options"], + private chain: Chain + ) { + super(projectPath, options); + } + + static midnightReadContractCode = (contractName: string, index: number) => { + return `const { contractInfo: contractInfo${index}, contractAddress: contractAddress${index}, zkConfigPath: zkConfigPath${index} } = readMidnightContract("${contractName}", "contract-${contractName}.json");` + } + + static midnightImportBlock = (projectName: string, contractName: string, extension: string = "/contract", importPostfix: string = "Contract") => { + const safeCodeContractName = + ChainMidnightPackage.safeCodeContractName(contractName); + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return ` + import * as ${safeCodeContractName}${importPostfix} from "@${projectName}/midnight-contract-${safePackageName}${extension || ''}"; + `; + }; + + static midnightPrimitiveBlock = (contractName: string) => { + const safeCodeContractName = + ChainMidnightPackage.safeCodeContractName(contractName); + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return midnightPrimitiveBlock_(safeCodeContractName, safePackageName); + }; + + static contractBatcher = (contractName: string, index: number) => { + const safeCodeContractName = + ChainMidnightPackage.safeCodeContractName(contractName); + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return contractBatcher_(safeCodeContractName, safePackageName, index); + }; + + static getReadContractCode = (contractName: string) => { + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return getReadContractCode_(safePackageName); + }; + + static safeCodeContractName(contractName: string): string { + return contractName.replace(/[^a-zA-Z0-9_]/g, "_"); + } + + static safePackageName(contractName: string): string { + return contractName.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); + } + + static midnightGrammar(contractName: string): { + customGrammar: string; + builtInGrammar: string; + } { + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return midnightGrammar_(safePackageName); + } + + static midnightStateMachine(contractName: string): string { + const safePackageName = ChainMidnightPackage.safePackageName(contractName); + return midnightStateMachine_(safePackageName); + } + + public async generate(): Promise { + const chainPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + this.chain + "-contracts" + ); + + const packageInfo = await scaffoldMidnightProject( + chainPath, + this.options.projectName, + EFFECTSTREAM_VERSION, + this.options.contracts.midnight?.map((contract) => ({ + safeCodeName: ChainMidnightPackage.safeCodeContractName(contract), + safePackageName: ChainMidnightPackage.safePackageName(contract), + })) || [] + ); + + const subPackages: PackageInfo[] = []; + for (const contract of this.options.contracts.midnight || []) { + const safeCodeContractName = + ChainMidnightPackage.safeCodeContractName(contract); + const safePackageName = ChainMidnightPackage.safePackageName(contract); + const contractPath = path.join( + this.projectPath, + "packages", + "shared", + "contracts", + "midnight-contracts", + safePackageName + ); + + const contractFile = midnightContractOptions.find( + (o) => o.value === contract + )!.file; + + const contractInfo = await scaffoldMidnightContract( + contractPath, + this.options.projectName, + safeCodeContractName, + safePackageName, + contractFile, + EFFECTSTREAM_VERSION + ); + subPackages.push(contractInfo); + } + + return { name: packageInfo.name, path: packageInfo.path, subPackages }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/client-batcher.ts b/packages/build-tools/template-generator/src/packages/client-batcher.ts new file mode 100644 index 000000000..3cbe4a4a6 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/client-batcher.ts @@ -0,0 +1,105 @@ +import { Package, PackageInfo } from "./abstract-package.ts"; +import { DenoJsonFile } from "../file-types/deno-json-file.ts"; +import { TypescriptFile } from "../file-types/typescript-file.ts"; +import { EFFECTSTREAM_VERSION } from "../options.ts"; +import * as path from "jsr:@std/path"; +import { copyFiles } from "../file-operations.ts"; +import { ChainMidnightPackage } from "./chain-midnight.ts"; + +export class ClientBatcherPackage extends Package { + public async generate(): Promise { + if (!this.options.devOptions.useBatcher) { + return null; + } + + const batcherPath = path.join( + this.projectPath, + "packages", + "client", + "batcher" + ); + const packageName = `@${this.options.projectName}/batcher`; + + const isMidnightEnabled = !!this.options.contracts.midnight; + const isBitcoinEnabled = !!this.options.contracts.bitcoin; + const isCardanoEnabled = !!this.options.contracts.cardano; + const isEvmEnabled = !!this.options.contracts.evm; + const isAvailEnabled = !!this.options.contracts.avail; + const isEffectstreaml2Enabled = !!this.options.contracts.evm?.some(contract => contract === "effectstreaml2"); + + const midnightImportBlockCodeA = this.options.chains.includes("midnight") ? + this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightImportBlock(this.options.projectName, contract, "", "Info")).join("\n") : "" + const midnightImportBlockCodeB = this.options.chains.includes("midnight") ? + this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightImportBlock(this.options.projectName, contract, "/contract", "Contract")).join("\n") : "" + const midnightReadContractCodeA = this.options.chains.includes("midnight") ? + this.options.contracts.midnight?.map((contract, index): string => { + return ChainMidnightPackage.midnightReadContractCode(contract, index) + }).join("\n") : "" + + const midnightReadContractCode = isMidnightEnabled ? + this.options.contracts.midnight?.map((contract, index): string => { + return `const { contractInfo${index}, contractAddress${index}, zkConfigPath${index} } = ${ChainMidnightPackage.getReadContractCode(contract)}` + }).join("\n") : "" + + const currentDir = path.dirname(path.fromFileUrl(import.meta.url)); + await copyFiles( + path.join(currentDir, "templates", "batcher", "src"), + path.join(batcherPath, "src"), + { + "scope": this.options.projectName, + }, // replacements + { + "BITCOIN-BLOCK": isBitcoinEnabled, + "CARDANO-BLOCK": isCardanoEnabled, + "EVM-BLOCK": isEvmEnabled, + "AVAIL-BLOCK": isAvailEnabled, + "EFFECTSTREAM-L2-BLOCK": isEffectstreaml2Enabled, + "MIDNIGHT-BLOCK": isMidnightEnabled, + }, // enable code blocks + { + "MIDNIGHT-IMPORT-BLOCK": midnightImportBlockCodeA + "\n" + midnightImportBlockCodeB + "\n" + midnightReadContractCodeA, + "MIDNIGHT-READ-CONTRACT-BLOCK": midnightReadContractCode || "", + "MIDNIGHT-ADAPTER-BLOCK": this.options.contracts.midnight?.map((contract, index): string => { + return ChainMidnightPackage.contractBatcher(contract, index) + }).join("\n") || "", + "MIDNIGHT-EXPORT-BLOCK": + `export const midnightAdapters = { ${this.options.contracts.midnight?.map((contract, index): string => { + return `"${contract}": midnightAdapter_${ChainMidnightPackage.safeCodeContractName(contract)}` + }).join(",") || ""} };`, + } + ); + + const deno = { + name: packageName, + exports: { + ".": "./src/main.ts", + }, + imports: { + "@midnight-ntwrk/compact-runtime": isMidnightEnabled + ? "npm:@midnight-ntwrk/compact-runtime@0.9.0" + : undefined, + "@midnight-ntwrk/wallet-sdk-address-format": isMidnightEnabled + ? "npm:@midnight-ntwrk/wallet-sdk-address-format@2.0.0" + : undefined, + "@paimaexample/batcher": "jsr:@paimaexample/batcher@" + EFFECTSTREAM_VERSION, + "@paimaexample/utils": "jsr:@paimaexample/utils@" + EFFECTSTREAM_VERSION, + "@paimaexample/concise": "jsr:@paimaexample/concise@" + EFFECTSTREAM_VERSION, + "@paimaexample/coroutine": + "jsr:@paimaexample/coroutine@" + EFFECTSTREAM_VERSION, + effection: "npm:effection@^3.5.0", + "@std/path": "jsr:@std/path@^1.1.3", + viem: "npm:viem@2.37.3", + "bitcoinjs-message": isBitcoinEnabled ? "npm:bitcoinjs-message@^2.2.0" : undefined, + "bitcoinjs-lib": isBitcoinEnabled ? "npm:bitcoinjs-lib@6.1.5" : undefined, + "ecpair": isBitcoinEnabled ? "npm:ecpair@2.1.0" : undefined, + "tiny-secp256k1": isBitcoinEnabled ? "npm:tiny-secp256k1@2.2.3" : undefined, + }, + tasks: { + start: "deno run -A --unstable-detect-cjs src/main.ts", + }, + }; + await new DenoJsonFile(path.join(batcherPath, "deno.json"), deno).write(); + + return { name: packageName, path: batcherPath }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/client-database.ts b/packages/build-tools/template-generator/src/packages/client-database.ts new file mode 100644 index 000000000..8802b2587 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/client-database.ts @@ -0,0 +1,51 @@ +import * as path from "jsr:@std/path"; +import { Package, PackageInfo } from './abstract-package.ts'; +import { DenoJsonFile } from '../file-types/deno-json-file.ts'; +import { SqlFile } from '../file-types/sql-file.ts'; +import { copyFiles } from '../file-operations.ts'; +import { EFFECTSTREAM_VERSION } from '../options.ts'; + +export class ClientDatabasePackage extends Package { + public async generate(): Promise { + const dbPath = path.join(this.projectPath, 'packages', 'client', 'database'); + const packageName = `@${this.options.projectName}/database`; + + const currentDir = path.dirname(path.fromFileUrl(import.meta.url)); + const folders = [[], ["src"], ["src", "migrations"], ["src", "sql"]]; + + for (const folder of folders) { + await copyFiles( + path.join(currentDir, "templates", "database", ...folder), + path.join(dbPath, ...folder), + {"scope": this.options.projectName} // replacements + ); + } + + const deno = { + "name": packageName, + "version": "0.3.0", + "license": "MIT", + "exports": "./src/mod.ts", + "imports": { + "@paimaexample/config": "jsr:@paimaexample/config@" + EFFECTSTREAM_VERSION, + "@paimaexample/runtime": "jsr:@paimaexample/runtime@" + EFFECTSTREAM_VERSION, + "@paimaexample/sm": "jsr:@paimaexample/sm@" + EFFECTSTREAM_VERSION, + "@pgtyped/runtime": "npm:@pgtyped/runtime@2.4.2", + "@paimaexample/db": "jsr:@paimaexample/db@" + EFFECTSTREAM_VERSION, + "pg": "npm:pg@^8.14.0", + "effection": "npm:effection@3.5.0" + }, + "tasks": { + "_pgtyped:my-sql": "deno run -A @paimaexample/db/db-wait && deno run -A @paimaexample/db/apply-migrations && deno run -A --unstable-raw-imports sql-to-ts.ts && pgtyped -c ./pgtypedconfig.json", + "pgtyped:update": "concurrently --raw --kill-others \"deno run -A @paimaexample/db/db-up\" \"deno task _pgtyped:my-sql\"" + } + }; + + await new DenoJsonFile( + path.join(dbPath, 'deno.json'), + deno + ).write(); + + return { name: packageName, path: dbPath }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/client-node.ts b/packages/build-tools/template-generator/src/packages/client-node.ts new file mode 100644 index 000000000..58ad3962b --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/client-node.ts @@ -0,0 +1,97 @@ +import * as path from "jsr:@std/path"; +import { Package, PackageInfo } from './abstract-package.ts'; +import { DenoJsonFile } from '../file-types/deno-json-file.ts'; +import { TypescriptFile } from '../file-types/typescript-file.ts'; +import { copyFiles } from '../file-operations.ts'; +import { EFFECTSTREAM_VERSION } from '../options.ts'; +import { ChainEVMPackage } from './chain-evm.ts'; +import { ChainMidnightPackage } from './chain-midnight.ts'; +import { ChainAvailPackage } from './chain-avail.ts'; +import { ChainCardanoPackage } from './chain-cardano.ts'; +import { ChainBitcoinPackage } from './chain-bitcoin.ts'; + +export class ClientNodePackage extends Package { + public async generate(): Promise { + const nodePath = path.join(this.projectPath, 'packages', 'client', 'node'); + const packageName = `@${this.options.projectName}/node`; + + const batcher_block = true; + const explorer_block = this.options.frontends.includes('explorer'); + const standalone_frontend_block = this.options.frontends.includes('standalone-esbuild'); + + const currentDir = path.dirname(path.fromFileUrl(import.meta.url)); + const folders = [[], ["src"], ["scripts"]]; + for (const folder of folders) { + await copyFiles( + path.join(currentDir, "templates", "node", ...folder), + path.join(nodePath, ...folder), + { + "scope": this.options.projectName, + "isCardano": this.options.chains.includes("cardano") ? "true" : "false", + "isAvail": this.options.chains.includes("avail") ? "true" : "false", + "isMidnight": this.options.chains.includes("midnight") ? "true" : "false", + "isEvm": this.options.chains.includes("evm") ? "true" : "false", + "isBitcoin": this.options.chains.includes("bitcoin") ? "true" : "false", + }, + { + "STANDALONE-FRONTEND-BLOCK": standalone_frontend_block, + "EXPLORER-BLOCK": explorer_block, + "BATCHER-BLOCK": batcher_block, + "CARDANO-BLOCK": this.options.chains.includes("cardano"), + "AVAIL-BLOCK": this.options.chains.includes("avail"), + "MIDNIGHT-BLOCK": this.options.chains.includes("midnight"), + "EVM-BLOCK": this.options.chains.includes("evm"), + "BITCOIN-BLOCK": this.options.chains.includes("bitcoin"), + }, + { + "EVM-STM-BLOCK": this.options.contracts.evm?.map(contract => ChainEVMPackage.evmStateMachine(contract)).join("\n") || "", + "MIDNIGHT-STM-BLOCK": this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightStateMachine(contract)).join("\n") || "", + "AVAIL-STM-BLOCK": this.options.contracts.avail?.map(contract => ChainAvailPackage.availStateMachine(contract)).join("\n") || "", + "CARDANO-STM-BLOCK": this.options.contracts.cardano?.map(contract => ChainCardanoPackage.cardanoStateMachine(contract)).join("\n") || "", + "BITCOIN-STM-BLOCK": this.options.contracts.bitcoin?.map(contract => ChainBitcoinPackage.bitcoinStateMachine(contract)).join("\n") || "", + } + ); + } + + const deno = { + "name": packageName, + "version": "0.3.0", + "license": "MIT", + "exports": { + ".": "./src/main.ts" + }, + "tasks": { + "check": "deno check --unstable-raw-imports src/main.ts", + // Open chrome://inspect to see the inspector + "node:start": "deno run -A --inspect --unstable-raw-imports src/main.ts", + "dev": "deno task -f @paimaexample/tui clean && NODE_ENV=development deno run -A --check --unstable-raw-imports scripts/start.ts", + "test": "deno run -A --unstable-raw-imports --check scripts/e2e.test.ts" + }, + "imports": { + "@paimaexample/concise": "jsr:@paimaexample/concise@" + EFFECTSTREAM_VERSION, + "@paimaexample/config": "jsr:@paimaexample/config@" + EFFECTSTREAM_VERSION, + "@paimaexample/log": "jsr:@paimaexample/log@" + EFFECTSTREAM_VERSION, + "@paimaexample/orchestrator": "jsr:@paimaexample/orchestrator@" + EFFECTSTREAM_VERSION, + "@paimaexample/runtime": "jsr:@paimaexample/runtime@" + EFFECTSTREAM_VERSION, + "@paimaexample/tui": "jsr:@paimaexample/tui@" + EFFECTSTREAM_VERSION, + "@paimaexample/utils": "jsr:@paimaexample/utils@" + EFFECTSTREAM_VERSION, + "@paimaexample/sm": "jsr:@paimaexample/sm@" + EFFECTSTREAM_VERSION, + "@paimaexample/coroutine": "jsr:@paimaexample/coroutine@" + EFFECTSTREAM_VERSION, + "@paimaexample/db": "jsr:@paimaexample/db@" + EFFECTSTREAM_VERSION, + "fastify": "npm:fastify@^5.4.0", + "pg": "npm:pg@^8.14.0", + "@sinclair/typebox": "npm:@sinclair/typebox@^0.34.30", + "effection": "npm:effection@^3.5.0", + "@paimaexample/explorer": "npm:@paimaexample/explorer@" + EFFECTSTREAM_VERSION, + "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime@^0.3.0" + } + }; + + await new DenoJsonFile( + path.join(nodePath, 'deno.json'), + deno + ).write(); + + return { name: packageName, path: nodePath }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/shared-data-types.ts b/packages/build-tools/template-generator/src/packages/shared-data-types.ts new file mode 100644 index 000000000..f0c99c516 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/shared-data-types.ts @@ -0,0 +1,92 @@ +import * as path from "jsr:@std/path"; +import { Package, PackageInfo } from "./abstract-package.ts"; +import { DenoJsonFile } from "../file-types/deno-json-file.ts"; +import { copyFiles } from "../file-operations.ts"; +import { EFFECTSTREAM_VERSION } from "../options.ts"; +import { ChainMidnightPackage } from "./chain-midnight.ts"; +import { ChainEVMPackage } from "./chain-evm.ts"; +import { ChainAvailPackage } from "./chain-avail.ts"; +import { ChainCardanoPackage } from "./chain-cardano.ts"; +import { ChainBitcoinPackage } from "./chain-bitcoin.ts"; + +export class SharedDataTypesPackage extends Package { + public async generate(): Promise { + const dataTypesPath = path.join( + this.projectPath, + "packages", + "shared", + "data-types" + ); + const packageName = `@${this.options.projectName}/data-types`; + + + + const midnightImportBlockCode = this.options.chains.includes("midnight") ? + this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightImportBlock(this.options.projectName, contract)).join("\n") : "" + + const midnightPrimitiveBlockCode = this.options.chains.includes("midnight") ? + this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightPrimitiveBlock(contract)).join("\n") : "" + + + const currentDir = path.dirname(path.fromFileUrl(import.meta.url)); + const folders = [["src"]]; + for (const folder of folders) { + await copyFiles( + path.join(currentDir, "templates", "data-types", ...folder), + path.join(dataTypesPath, ...folder), + { "scope": this.options.projectName }, // replacements + { + "EVM-BLOCK": this.options.chains.includes("evm"), + "MIDNIGHT-BLOCK": this.options.chains.includes("midnight"), + "AVAIL-BLOCK": this.options.chains.includes("avail"), + "CARDANO-BLOCK": this.options.chains.includes("cardano"), + "BITCOIN-BLOCK": this.options.chains.includes("bitcoin"), + }, + { + "EVM-IMPORT-BLOCK": "", + "EVM-PRIMITIVE-BLOCK": this.options.contracts.evm?.map(contract => ChainEVMPackage.evmPrimitiveBlock(contract, ChainEVMPackage.safePackageName(contract))).join("\n") || "", + "EVM-GRAMMAR-BLOCK": this.options.contracts.evm?.map(contract => ChainEVMPackage.evmGrammar(contract)).map(grammar => grammar.builtInGrammar).join("\n") || "", + "EVM-GRAMMAR-L2-BLOCK": this.options.contracts.evm?.map(contract => ChainEVMPackage.evmGrammar(contract)).map(grammar => grammar.customGrammar).join("\n") || "", + + "MIDNIGHT-IMPORT-BLOCK": midnightImportBlockCode || "", + "MIDNIGHT-PRIMITIVE-BLOCK": midnightPrimitiveBlockCode || "", + "MIDNIGHT-GRAMMAR-BLOCK": this.options.contracts.midnight?.map(contract => ChainMidnightPackage.midnightGrammar(contract)).map(grammar => grammar.builtInGrammar).join("\n") || "", + + "AVAIL-IMPORT-BLOCK": "", + "AVAIL-PRIMITIVE-BLOCK": this.options.contracts.avail?.map(contract => ChainAvailPackage.availPrimitiveBlock(contract)).join("\n") || "", + "AVAIL-GRAMMAR-BLOCK": this.options.contracts.avail?.map(contract => ChainAvailPackage.availGrammar(contract)).map(grammar => grammar.builtInGrammar).join("\n") || "", + + "CARDANO-IMPORT-BLOCK": "", + "CARDANO-PRIMITIVE-BLOCK": this.options.contracts.cardano?.map(contract => ChainCardanoPackage.cardanoPrimitiveBlock(contract)).join("\n") || "", + "CARDANO-GRAMMAR-BLOCK": this.options.contracts.cardano?.map(contract => ChainCardanoPackage.cardanoGrammar(contract)).map(grammar => grammar.builtInGrammar).join("\n") || "", + + "BITCOIN-IMPORT-BLOCK": "", + "BITCOIN-PRIMITIVE-BLOCK": this.options.contracts.bitcoin?.map(contract => ChainBitcoinPackage.bitcoinPrimitiveBlock(contract)).join("\n") || "", + "BITCOIN-GRAMMAR-BLOCK": this.options.contracts.bitcoin?.map(contract => ChainBitcoinPackage.bitcoinGrammar(contract)).map(grammar => grammar.builtInGrammar).join("\n") || "", + } + ); + } + + const deno = { + name: packageName, + version: "0.3.0", + license: "MIT", + exports: { + "./localhostConfig": "./src/localhostConfig.ts", + "./grammar": "./src/grammar.ts", + }, + imports: { + "@paimaexample/concise": "jsr:@paimaexample/concise@" + EFFECTSTREAM_VERSION, + "@paimaexample/config": "jsr:@paimaexample/config@" + EFFECTSTREAM_VERSION, + "@paimaexample/utils": "jsr:@paimaexample/utils@" + EFFECTSTREAM_VERSION, + "@paimaexample/db": "jsr:@paimaexample/db@" + EFFECTSTREAM_VERSION, + "@paimaexample/sm": "jsr:@paimaexample/sm@" + EFFECTSTREAM_VERSION, + viem: "npm:viem@2.37.3", + "@sinclair/typebox": "npm:@sinclair/typebox@0.34.41", + }, + }; + await new DenoJsonFile(path.join(dataTypesPath, "deno.json"), deno).write(); + + return { name: packageName, path: dataTypesPath }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/standalone-esbuild.ts b/packages/build-tools/template-generator/src/packages/standalone-esbuild.ts new file mode 100644 index 000000000..24b5f47c9 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/standalone-esbuild.ts @@ -0,0 +1,37 @@ +import * as path from "@std/path"; +import { Package, PackageInfo } from './abstract-package.ts'; +import { copyFiles } from "../file-operations.ts"; +import { EFFECTSTREAM_VERSION } from "../options.ts"; + + +export class StandaloneEsbuildPackage extends Package { + public async generate(): Promise { + if (!this.options.frontends.includes('standalone-esbuild')) { + return null; + } + + const frontendPath = path.join(this.projectPath, 'frontend', 'standalone'); + const packageName = `${this.options.projectName}-frontend-esbuild`; + + const currentDir = path.dirname(path.fromFileUrl(import.meta.url)); + const templateDir = path.join(currentDir, 'templates', 'frontend-min'); + + await copyFiles( + templateDir, + frontendPath, + { + "projectName": this.options.projectName, + "EFFECTSTREAM-VERSION": EFFECTSTREAM_VERSION, + }, + { + "EVM-BLOCK": this.options.chains.includes("evm"), + "MIDNIGHT-BLOCK": this.options.chains.includes("midnight"), + "AVAIL-BLOCK": this.options.chains.includes("avail"), + "CARDANO-BLOCK": this.options.chains.includes("cardano"), + "BITCOIN-BLOCK": this.options.chains.includes("bitcoin"), + }, + ); + + return { name: packageName, path: frontendPath }; + } +} diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-avail.ts.reaname b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-avail.ts.reaname new file mode 100644 index 000000000..e69de29bb diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-bitcoin.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-bitcoin.ts.rename new file mode 100644 index 000000000..f0cafb558 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-bitcoin.ts.rename @@ -0,0 +1,84 @@ + +import * as bitcoinMessage from "bitcoinjs-message"; +import { buildBitcoinSignatureMessage, BitcoinAdapter } from "@paimaexample/batcher"; +import * as bitcoin from "bitcoinjs-lib"; +import * as ecpair from "ecpair"; +import * as tinysecp from "tiny-secp256k1"; + +const isEnvTrue = (key: string) => ["true", "1", "yes", "y"].includes((Deno.env.get(key) || "").toLowerCase()); + +const bitcoin_enabled = !isEnvTrue("DISABLE_BITCOIN"); + +// Bitcoin Adapter +const BITCOIN_SEED = "my-super-secret-regtest-demo-seed-e2e"; +export const bitcoinAdapter = bitcoin_enabled ? new BitcoinAdapter({ + rpcUrl: "http://127.0.0.1:18443", + rpcUser: "dev", + rpcPass: "devpassword", + seed: BITCOIN_SEED, +}) : undefined; + + +// Bitcoin Adapter Caller +// This is used to call the adapter defined above. +const BATCHER_ENDPOINT = "http://localhost:3334/send-input"; + +const ECPair = ecpair.ECPairFactory(tinysecp); + +interface BatcherResponse { + success: boolean; + message: string; + inputsProcessed: number; + transactionHash?: string; + rollup?: number; +} + +interface BitcoinRequest { + toAddress: string; + amountSats: number; +} + +export async function sendBitcoin( + privateKeyWIF: string, + payload: BitcoinRequest, + confirmationLevel: "no-wait" | "wait-receipt" | "wait-effectstream-processed" = "no-wait", + network: bitcoin.Network = bitcoin.networks.regtest, + target: string = "bitcoin" +): Promise { + const keyPair = ECPair.fromWIF(privateKeyWIF, network); + const { address } = bitcoin.payments.p2wpkh({ pubkey: keyPair.publicKey, network }); + + if (!address) throw new Error("Could not derive address from private key"); + + const timestamp = new Date().toISOString(); + const message = buildBitcoinSignatureMessage(payload, timestamp); + + // Sign the message + const signature = bitcoinMessage.sign(message, keyPair.privateKey! as any, keyPair.compressed).toString('base64'); + + const body = { + address, + input: JSON.stringify(payload), + signature, + timestamp, + target, + addressType: -1, + }; + + const response = await fetch(BATCHER_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: body, + confirmationLevel, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send Bitcoin transaction: ${response.statusText}`); + } + + return response.json(); +} diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-effectstreaml2.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-effectstreaml2.ts.rename new file mode 100644 index 000000000..09cfa5387 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-effectstreaml2.ts.rename @@ -0,0 +1,24 @@ +import { + PaimaL2DefaultAdapter, + } from "@paimaexample/batcher"; + import { contractAddressesEvmMain } from "@[scope]/evm-contracts"; + + // Config values mirroring ./packages/client/node/scripts/start.ts + const paimaL2Address = contractAddressesEvmMain()["chain31337"][ + "PaimaL2ContractModule#MyPaimaL2Contract" + ] as `0x${string}`; + const paimaSyncProtocolName = "parallelEvmRPC_fast"; + // In real cases use Deno.env for reading private key + const batcherPrivateKey = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + + // Defaults consistent with E2E usage + const paimaL2Fee = 0n; // old batcher defaulted to 0 for local dev + + // PaimaL2 EVM adapter + export const effectstreaml2Adapter = new PaimaL2DefaultAdapter( + paimaL2Address, + batcherPrivateKey, + paimaL2Fee, + paimaSyncProtocolName, + ); \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-evm.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-evm.ts.rename new file mode 100644 index 000000000..e69de29bb diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-midnight.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-midnight.ts.rename new file mode 100644 index 000000000..47d665d74 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/batcher/src/adapter-midnight.ts.rename @@ -0,0 +1,18 @@ +import { MidnightAdapter } from "@paimaexample/batcher"; +import { readMidnightContract } from "@paimaexample/midnight-contracts/read-contract"; + + +/** MIDNIGHT-IMPORT-BLOCK */ +/** MIDNIGHT-READ-CONTRACT-BLOCK */ + +const GENESIS_MINT_WALLET_SEED = "0000000000000000000000000000000000000000000000000000000000000001"; +const indexer = "http://localhost:8088/api/v1/graphql"; +const indexerWS = "ws://localhost:8088/api/v1/graphql/ws"; +const node = "http://localhost:9944"; +const proofServer = "http://localhost:6300"; +const networkID = 0; // NetworkId.Undeployed, +const syncProtocolName = "parallelMidnight"; + +/** MIDNIGHT-ADAPTER-BLOCK */ + +/** MIDNIGHT-EXPORT-BLOCK */ \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/config.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/config.ts.rename new file mode 100644 index 000000000..5675f5589 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/batcher/src/config.ts.rename @@ -0,0 +1,20 @@ +import { + FileStorage, + type BatcherConfig, + type DefaultBatcherInput, +} from "@paimaexample/batcher"; + +const batchIntervalMs = 1000; +const port = Number(Deno.env.get("BATCHER_PORT") ?? "3334"); + +// Batcher config matching old behavior +export const config: BatcherConfig = { + pollingIntervalMs: batchIntervalMs, + enableHttpServer: true, + namespace: "", // TODO start using namespace for signature verification security + confirmationLevel: "wait-effectstream-processed", + enableEventSystem: true, // Important for adding state transitions to console logs + port, +}; + +export const storage = new FileStorage("./batcher-data"); diff --git a/packages/build-tools/template-generator/src/packages/templates/batcher/src/main.ts.rename b/packages/build-tools/template-generator/src/packages/templates/batcher/src/main.ts.rename new file mode 100644 index 000000000..8b066b060 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/batcher/src/main.ts.rename @@ -0,0 +1,87 @@ +import { main, suspend } from "effection"; +import { createNewBatcher } from "@paimaexample/batcher"; +import { config, storage } from "./config.ts"; + +/** AVAIL-BLOCK */ +/** AVAIL-BLOCK */ + +/** BITCOIN-BLOCK */ +import { bitcoinAdapter } from "./adapter-bitcoin.ts"; +/** BITCOIN-BLOCK */ + +/** CARDANO-BLOCK */ +/** CARDANO-BLOCK */ + +/** EVM-BLOCK */ +/** EVM-BLOCK */ + +/** MIDNIGHT-BLOCK */ +import * as midnightAdapters from "./adapter-midnight.ts"; +/** MIDNIGHT-BLOCK */ + +/** EFFECTSTREAM-L2-BLOCK */ +import { effectstreaml2Adapter } from "./adapter-effectstreaml2.ts"; +/** EFFECTSTREAM-L2-BLOCK */ + +const batcher = createNewBatcher(config, storage); +const batchIntervalMs = 1000; + +const isEnvTrue = (key: string) => ["true", "1", "yes", "y"].includes((Deno.env.get(key) || "").toLowerCase()); + +/** EFFECTSTREAM-L2-BLOCK */ +batcher + .addBlockchainAdapter("effectstreaml2", effectstreaml2Adapter, { criteriaType: "time", timeWindowMs: batchIntervalMs }) + .setDefaultTarget("effectstreaml2"); +/** EFFECTSTREAM-L2-BLOCK */ + +/** MIDNIGHT-BLOCK */ +for (const [contract, adapter] of Object.entries(midnightAdapters)) { +batcher + .addBlockchainAdapter(contract, adapter, { criteriaType: "size", maxBatchSize: 1 }); +} +/** MIDNIGHT-BLOCK */ + +/** BITCOIN-BLOCK */ +batcher + .addBlockchainAdapter("bitcoin", bitcoinAdapter, { criteriaType: "hybrid", maxBatchSize: 5, timeWindowMs: batchIntervalMs }); +/** BITCOIN-BLOCK */ + +// E2E-specific startup banner via state transition +batcher.addStateTransition("startup", ({ publicConfig }) => { + const banner = + `🧪 E2E Batcher startup - polling every ${publicConfig.pollingIntervalMs} ms\n` + + ` | 📍 Default Target: ${publicConfig.defaultTarget}\n` + + ` | ⛓️ Blockchain Adapter Targets: ${ + publicConfig.adapterTargets.join(", ") + }\n` + + ` | 📦 Batching Criteria: ${ + Object.entries(publicConfig.criteriaTypes).map(([target, type]) => + `${target}=${type}` + ).join(", ") + }\n` + + ` | 📋 Press Ctrl+C to stop gracefully`; + console.log(banner); +}) +.addStateTransition("http:start", ({ port }) => { + const publicConfig = batcher.getPublicConfig(); + const httpInfo = `🌐 HTTP Server started for E2E\n` + + ` | URL: http://localhost:${port}\n` + + ` | Confirmation: ${publicConfig.confirmationLevel}\n` + + ` | Events Enabled: ${publicConfig.enableEventSystem}\n` + + ` | Polling: ${publicConfig.pollingIntervalMs} ms`; + console.log(httpInfo); +}); + +main(function* () { + console.log("🚀 Starting Batcher..."); + try { + // Run the batcher with Effection structured concurrency + yield* batcher.runBatcher(); + } catch (error) { + console.error("❌ Batcher error:", error); + // Trigger graceful shutdown on error + yield* batcher.gracefulShutdownOp(); + } + // Keep the main operation alive + yield* suspend(); +}); diff --git a/packages/build-tools/template-generator/src/packages/templates/data-types/src/grammar.ts.rename b/packages/build-tools/template-generator/src/packages/templates/data-types/src/grammar.ts.rename new file mode 100644 index 000000000..873e11f88 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/data-types/src/grammar.ts.rename @@ -0,0 +1,22 @@ +import type { GrammarDefinition } from "@paimaexample/concise"; +import { builtinGrammars } from "@paimaexample/sm/grammar"; +import { Type } from "@sinclair/typebox"; + +export const effectstreamL2Grammar = { + /** EVM-GRAMMAR-L2-BLOCK */ +} as const satisfies GrammarDefinition; + +export const grammar = { + ...effectstreamL2Grammar, + + /** MIDNIGHT-GRAMMAR-BLOCK */ + + /** EVM-GRAMMAR-BLOCK */ + + /** AVAIL-GRAMMAR-BLOCK */ + + /** CARDANO-GRAMMAR-BLOCK */ + + /** BITCOIN-GRAMMAR-BLOCK */ + +} as const satisfies GrammarDefinition; diff --git a/packages/build-tools/template-generator/src/packages/templates/data-types/src/localhostConfig.ts.rename b/packages/build-tools/template-generator/src/packages/templates/data-types/src/localhostConfig.ts.rename new file mode 100644 index 000000000..01d0e316a --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/data-types/src/localhostConfig.ts.rename @@ -0,0 +1,238 @@ +/** EVM-BLOCK */ +import { contractAddressesEvmMain } from "@[scope]/evm-contracts"; +/** EVM-BLOCK */ + +/** MIDNIGHT-BLOCK */ +import { readMidnightContract } from "@paimaexample/midnight-contracts/read-contract"; +/** MIDNIGHT-BLOCK */ + +import type { BlockNumber } from "@paimaexample/utils"; + +/** AVAIL-BLOCK */ +import { readAvailApplication } from "@[scope]/avail-contracts"; +/** AVAIL-BLOCK */ + +/** EVM-IMPORT-BLOCK */ +/** MIDNIGHT-IMPORT-BLOCK */ +/** BITCOIN-IMPORT-BLOCK */ + +import { + ConfigBuilder, + ConfigNetworkType, + ConfigSyncProtocolType, +} from "@paimaexample/config"; +import { hardhat } from "viem/chains"; +import { getConnection } from "@paimaexample/db"; + +import * as builtin from "@paimaexample/sm/builtin"; + +import { + PrimitiveTypeAvailGeneric, + PrimitiveTypeEVMERC1155, + PrimitiveTypeEVMERC20, + PrimitiveTypeEVMERC721, + PrimitiveTypeEVMPaimaL2, + PrimitiveTypeMidnightGeneric, + PrimitiveTypeBitcoinAddress, +} from "@paimaexample/sm/builtin"; + +/** + * Let check if the db. + * If empty then the db is not initialized, and use the current time for the NTP sync. + * If not, we recreate the original state configuration. + */ + +const mainSyncProtocolName = "mainNtp"; +let launchStartTime: number | undefined; +const dbConn = getConnection(); +try { + // TODO Update to effectstream.sync_protocol_pagination + const result = await dbConn.query(` + SELECT * FROM paima.sync_protocol_pagination + WHERE protocol_name = '${mainSyncProtocolName}' + ORDER BY page_number ASC + LIMIT 1 + `); + if (!result || !result.rows.length) { + throw new Error("DB is empty"); + } + launchStartTime = result.rows[0].page.root - + (result.rows[0].page_number * 1000); +} catch { + // This is not an error. + // Do nothing, the DB has not been initialized yet. +} + +export const localhostConfig = new ConfigBuilder() + .setNamespace( + (builder) => builder.setSecurityNamespace("[scope]"), + ) + .buildNetworks((builder) => + builder + .addNetwork({ + name: "ntp", + type: ConfigNetworkType.NTP, + // Initial time for the Paima Engine Node. Unix Timestamp in milliseconds. + // Give 2 minutes to the server to start syncing. + // In development mode local chains can take a while to start and deploy contracts. + startTime: launchStartTime ?? new Date().getTime(), + // Block size is milliseconds, this will be used to sync other chains. + // Block times will be exact, and not affected by the network latency, or server time. + blockTimeMS: 1000, + }) + /** EVM-BLOCK */ + .addViemNetwork({ + ...hardhat, + name: "evmMain", + }) + /** EVM-BLOCK */ + + /** MIDNIGHT-BLOCK */ + .addNetwork({ + name: "midnight", + type: ConfigNetworkType.MIDNIGHT, + genesisHash: + "0x0000000000000000000000000000000000000000000000000000000000000001", + networkId: 0, + nodeUrl: "http://127.0.0.1:9944", + }) + /** MIDNIGHT-BLOCK */ + + /** AVAIL-BLOCK */ + .addNetwork({ + name: "avail", + type: ConfigNetworkType.AVAIL, + genesisSeed: "//Alice", + nodeUrl: "ws://127.0.0.1:9955/ws", + genesisHash: readAvailApplication().genesisHash, + caip2: `avail:local`, + }) + /** AVAIL-BLOCK */ + + /** CARDANO-BLOCK */ + .addNetwork({ + name: "yaci", + type: ConfigNetworkType.CARDANO, + nodeUrl: "http://127.0.0.1:10000", // yaci-devkit default URL + network: "yaci", + }) + /** CARDANO-BLOCK */ + + /** BITCOIN-BLOCK */ + .addNetwork({ + name: "bitcoin", + type: ConfigNetworkType.BITCOIN, + rpcUrl: "http://127.0.0.1:18443", // bitcoin core address + rpcAuth: { + username: "dev", + password: "devpassword", + }, + network: "regtest", + chainIdentifier: "regtest", + }) + /** BITCOIN-BLOCK */ + + ) + .buildDeployments((builder) => + builder + // .addDeployment( + // (networks) => networks.evmMain, + // (_network) => ({ + // name: "Erc721DevModule#Erc721Dev", + // address: contractAddressesEvmMain() + // .chain31337["Erc721DevModule#Erc721Dev"], + // }), + // ) + ).buildSyncProtocols((builder) => + builder + .addMain( + (networks) => networks.ntp, + (network, deployments) => ({ + name: mainSyncProtocolName, + type: ConfigSyncProtocolType.NTP_MAIN, + chainUri: "", + startBlockHeight: 1, + pollingInterval: 1000, + }), + ) + /** EVM-BLOCK */ + .addParallel((networks) => networks.evmMain, (network, deployments) => ({ + name: "mainEvmRPC", + type: ConfigSyncProtocolType.EVM_RPC_PARALLEL, + chainUri: network.rpcUrls.default.http[0], + startBlockHeight: 1, + pollingInterval: 500, + confirmationDepth: 1, + })) + /** EVM-BLOCK */ + + /** MIDNIGHT-BLOCK */ + .addParallel( + (networks) => networks.midnight, + (network, deployments) => ({ + name: "parallelMidnight", + type: ConfigSyncProtocolType.MIDNIGHT_PARALLEL, + startBlockHeight: 1, + pollingInterval: 1000, + indexer: "http://127.0.0.1:8088/api/v1/graphql", + indexerWs: "ws://127.0.0.1:8088/api/v1/graphql/ws", + }), + ) + /** MIDNIGHT-BLOCK */ + + /** AVAIL-BLOCK */ + .addParallel( + (networks) => (networks as any).avail, + (network, deployments) => ({ + name: "parallelAvail", + type: ConfigSyncProtocolType.AVAIL_PARALLEL, + rpc: network.nodeUrl, + lightClient: "http://127.0.0.1:7007", + startBlockHeight: 1, + pollingInterval: 1_000, + delayMs: 60_000, // 1 minute + }), + ) + /** AVAIL-BLOCK */ + + /** CARDANO-BLOCK */ + .addParallel( + (networks) => (networks as any).yaci, + (network, deployments) => ({ + name: "parallelUtxoRpc", + type: ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL, + rpcUrl: "http://127.0.0.1:50051", // dolos utxorpc address + startSlot: 1, + delayMs: 20000, + }), + ) + /** CARDANO-BLOCK */ + + /** BITCOIN-BLOCK */ + .addParallel( + (networks) => (networks as any).bitcoin, + (network, deployments) => ({ + name: "parallelBitcoin", + type: ConfigSyncProtocolType.BITCOIN_RPC_PARALLEL, + rpcUrl: "http://127.0.0.1:18443", // bitcoin core address + startBlockHeight: 0 as BlockNumber, + delayMs: 20000, + pollingInterval: 10_000, + confirmationDepth: 0, + }), + ) + /** BITCOIN-BLOCK */ + ) + .buildPrimitives((builder) => + builder + /** EVM-PRIMITIVE-BLOCK */ + + /** MIDNIGHT-PRIMITIVE-BLOCK */ + + /** AVAIL-PRIMITIVE-BLOCK */ + + /** CARDANO-PRIMITIVE-BLOCK */ + + /** BITCOIN-PRIMITIVE-BLOCK */ + ) + .build(); diff --git a/packages/build-tools/template-generator/src/packages/templates/database/package.json.rename b/packages/build-tools/template-generator/src/packages/templates/database/package.json.rename new file mode 100644 index 000000000..1b34021ba --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/package.json.rename @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@paima/pgtyped-cli": "^2.4.5", + "concurrently": "9.1.2" + } +} \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/database/pgtypedconfig.json b/packages/build-tools/template-generator/src/packages/templates/database/pgtypedconfig.json new file mode 100644 index 000000000..1826a14bb --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/pgtypedconfig.json @@ -0,0 +1,26 @@ +{ + "transforms": [ + { + "mode": "sql", + "include": "**/*.sql", + "emitTemplate": "{{dir}}/{{name}}.queries.ts" + }, + { + "mode": "ts", + "include": "**/*.ts", + "emitTemplate": "{{dir}}/{{name}}.types.ts" + } + ], + "srcDir": "./src/sql", + "failOnError": false, + "camelCaseColumnNames": false, + "db": { + "dbName": "postgres", + "user": "postgres", + "password": "postgres", + "host": "localhost", + "port": 5432, + "ssl": false + }, + "maxWorkerThreads": 1 +} \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/database/sql-to-ts.ts.rename b/packages/build-tools/template-generator/src/packages/templates/database/sql-to-ts.ts.rename new file mode 100644 index 000000000..289c8b49e --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/sql-to-ts.ts.rename @@ -0,0 +1,12 @@ +import { getConnection } from "@paimaexample/db"; +// TODO Update this to use the @paimaexample/db-emulator package +// import { standAloneApplyMigrations } from "@paimaexample/db-emulator"; +import { standAloneApplyMigrations } from "./src/patch-emulator.ts"; +import { migrationTable } from "./src/migration-order.ts"; +import { localhostConfig } from "@[scope]/data-types/localhostConfig"; + +// This helper applies Paima Engine Migrations to the database, so you can use it to generate the pgtyped files. +const db = await getConnection(); +await standAloneApplyMigrations(db, migrationTable, localhostConfig as any, {}); +console.log("✅ System & User migrations applied"); +Deno.exit(0); diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/migration-order.ts.rename b/packages/build-tools/template-generator/src/packages/templates/database/src/migration-order.ts.rename new file mode 100644 index 000000000..3aa67d5cf --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/migration-order.ts.rename @@ -0,0 +1,9 @@ +import type { DBMigrations } from "@paimaexample/runtime"; +import databaseSql from "./migrations/database.sql" with { type: "text" }; + +export const migrationTable: DBMigrations[] = [ + { + name: "database.sql", + sql: databaseSql, + }, +]; diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/migrations/database.sql b/packages/build-tools/template-generator/src/packages/templates/database/src/migrations/database.sql new file mode 100644 index 000000000..09e271180 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/migrations/database.sql @@ -0,0 +1,9 @@ +CREATE TABLE example_table ( + id SERIAL PRIMARY KEY, + chain TEXT NOT NULL, + action TEXT NOT NULL, + data text NOT NULL, + block_height INTEGER NOT NULL +); + +CREATE INDEX example_table_chain_index ON example_table(chain, block_height); \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/mod.ts.rename b/packages/build-tools/template-generator/src/packages/templates/database/src/mod.ts.rename new file mode 100644 index 000000000..b508786b9 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/mod.ts.rename @@ -0,0 +1,2 @@ +export * from "./sql/example-queries.queries.ts"; +export { migrationTable } from "./migration-order.ts"; diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/patch-emulator.ts.rename b/packages/build-tools/template-generator/src/packages/templates/database/src/patch-emulator.ts.rename new file mode 100644 index 000000000..90ea442bb --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/patch-emulator.ts.rename @@ -0,0 +1,81 @@ +import { run } from "effection"; +import { createDynamicTables } from "@paimaexample/db"; +import type { Client } from "pg"; +import { applyMigrations } from "@paimaexample/db/version"; +import type { SyncProtocolWithNetwork } from "@paimaexample/config"; +import { builtInPrimitivesMap } from "@paimaexample/sm"; + +// TODO Update this to use the internal patch-emulator.ts + +/** + * This is to generate the user/custom pgtyped files in compilation time + * MIGRATIONS environment variable is used to specify the path to the migrations folder. + * Every file in the migrations folder is executed in order. + * TODO: Implement how to manage the order of the migrations, e.g. 1.sql, 2.sql, 10.sql, etc. + */ + +export async function standAloneApplyMigrations( + db: Client, + migrationTable: /*DBMigrations[]*/ any[], + localhostConfig: SyncProtocolWithNetwork, + userDefinedPrimitives?: Record, +) { + const l: SyncProtocolWithNetwork = localhostConfig as any; + const config = Object.entries(l.primitives).map(([key, value]: [string, any]) => { + + const primitiveType = value.primitive.type; + const primitiveUniqueName = value.primitive.name; + const primitiveConfig = value.primitive; + const isBuiltInPrimitive = primitiveType in builtInPrimitivesMap; + const isUserDefinedPrimitive = userDefinedPrimitives && primitiveType in userDefinedPrimitives; + const classConfig = { + ...primitiveConfig, + instanceName: primitiveUniqueName, + } + if (isBuiltInPrimitive) { + new builtInPrimitivesMap[primitiveType as keyof typeof builtInPrimitivesMap](classConfig as any) ; + } else if (isUserDefinedPrimitive) { + new userDefinedPrimitives[primitiveType as keyof typeof userDefinedPrimitives](classConfig); + } else { + throw new Error(`Primitive ${primitiveType} not found`); + } + + return { + config: { + primitives: [{ + primitive: { + type: value.primitive.type, + name: value.primitive.name, + }, + }], + }, + }; + }); + + + await run(function* () { + return yield* createDynamicTables( + { + engine_current_version: "0.0.0", + engine_previous_version: "0.0.0", + app_previous_version: "0.0.0", + is_empty: true, + }, + 0, + db, + config as any, + ); + }); + const migrations = migrationTable; + + for (const migration of migrations) { + await applyMigrations( + db, + 0, + migration.name, + migration.sql, + false, + ); + } +} + diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.queries.ts.rename b/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.queries.ts.rename new file mode 100644 index 000000000..c41db130f --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.queries.ts.rename @@ -0,0 +1,102 @@ +/** Types generated for queries found in "src/sql/example-queries.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +export type NumberOrString = number | string; + +/** 'TableExists' parameters type */ +export type ITableExistsParams = void; + +/** 'TableExists' return type */ +export interface ITableExistsResult { + exists: boolean | null; +} + +/** 'TableExists' query type */ +export interface ITableExistsQuery { + params: ITableExistsParams; + result: ITableExistsResult; +} + +const tableExistsIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT EXISTS (\n SELECT FROM information_schema.tables \n WHERE table_schema = 'public'\n AND table_name = 'example_table'\n)"}; + +/** + * Query generated from SQL: + * ``` + * SELECT EXISTS ( + * SELECT FROM information_schema.tables + * WHERE table_schema = 'public' + * AND table_name = 'example_table' + * ) + * ``` + */ +export const tableExists = new PreparedQuery(tableExistsIR); + + +/** 'InsertData' parameters type */ +export interface IInsertDataParams { + action: string; + block_height: number; + chain: string; + data: string; +} + +/** 'InsertData' return type */ +export type IInsertDataResult = void; + +/** 'InsertData' query type */ +export interface IInsertDataQuery { + params: IInsertDataParams; + result: IInsertDataResult; +} + +const insertDataIR: any = {"usedParamSet":{"chain":true,"action":true,"data":true,"block_height":true},"params":[{"name":"chain","required":true,"transform":{"type":"scalar"},"locs":[{"a":81,"b":87}]},{"name":"action","required":true,"transform":{"type":"scalar"},"locs":[{"a":90,"b":97}]},{"name":"data","required":true,"transform":{"type":"scalar"},"locs":[{"a":100,"b":105}]},{"name":"block_height","required":true,"transform":{"type":"scalar"},"locs":[{"a":108,"b":121}]}],"statement":"INSERT INTO example_table \n (chain, action, data, block_height) \nVALUES \n (:chain!, :action!, :data!, :block_height!)"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO example_table + * (chain, action, data, block_height) + * VALUES + * (:chain!, :action!, :data!, :block_height!) + * ``` + */ +export const insertData = new PreparedQuery(insertDataIR); + + +/** 'GetDataByChain' parameters type */ +export interface IGetDataByChainParams { + chain: string; + limit: NumberOrString; + offset: NumberOrString; +} + +/** 'GetDataByChain' return type */ +export interface IGetDataByChainResult { + action: string; + block_height: number; + chain: string; + data: string; + id: number; +} + +/** 'GetDataByChain' query type */ +export interface IGetDataByChainQuery { + params: IGetDataByChainParams; + result: IGetDataByChainResult; +} + +const getDataByChainIR: any = {"usedParamSet":{"chain":true,"limit":true,"offset":true},"params":[{"name":"chain","required":true,"transform":{"type":"scalar"},"locs":[{"a":42,"b":48}]},{"name":"limit","required":true,"transform":{"type":"scalar"},"locs":[{"a":83,"b":89}]},{"name":"offset","required":true,"transform":{"type":"scalar"},"locs":[{"a":98,"b":105}]}],"statement":"SELECT * FROM example_table\nWHERE chain = :chain!\nORDER BY block_height DESC\nLIMIT :limit!\nOFFSET :offset!"}; + +/** + * Query generated from SQL: + * ``` + * SELECT * FROM example_table + * WHERE chain = :chain! + * ORDER BY block_height DESC + * LIMIT :limit! + * OFFSET :offset! + * ``` + */ +export const getDataByChain = new PreparedQuery(getDataByChainIR); + + diff --git a/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.sql b/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.sql new file mode 100644 index 000000000..a425b98b7 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/database/src/sql/example-queries.sql @@ -0,0 +1,100 @@ +/** Types generated for queries found in "src/sql/example-queries.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +export type NumberOrString = number | string; + +/** 'TableExists' parameters type */ +export type ITableExistsParams = void; + +/** 'TableExists' return type */ +export interface ITableExistsResult { + exists: boolean | null; +} + +/** 'TableExists' query type */ +export interface ITableExistsQuery { + params: ITableExistsParams; + result: ITableExistsResult; +} + +const tableExistsIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT EXISTS (\n SELECT FROM information_schema.tables \n WHERE table_schema = 'public'\n AND table_name = 'example_table'\n)"}; + +/** + * Query generated from SQL: + * ``` + * SELECT EXISTS ( + * SELECT FROM information_schema.tables + * WHERE table_schema = 'public' + * AND table_name = 'example_table' + * ) + * ``` + */ +export const tableExists = new PreparedQuery(tableExistsIR); + + +/** 'InsertData' parameters type */ +export interface IInsertDataParams { + action: string; + block_height: number; + chain: string; + data: string; +} + +/** 'InsertData' return type */ +export type IInsertDataResult = void; + +/** 'InsertData' query type */ +export interface IInsertDataQuery { + params: IInsertDataParams; + result: IInsertDataResult; +} + +const insertDataIR: any = {"usedParamSet":{"chain":true,"action":true,"data":true,"block_height":true},"params":[{"name":"chain","required":true,"transform":{"type":"scalar"},"locs":[{"a":81,"b":87}]},{"name":"action","required":true,"transform":{"type":"scalar"},"locs":[{"a":90,"b":97}]},{"name":"data","required":true,"transform":{"type":"scalar"},"locs":[{"a":100,"b":105}]},{"name":"block_height","required":true,"transform":{"type":"scalar"},"locs":[{"a":108,"b":121}]}],"statement":"INSERT INTO example_table \n (chain, action, data, block_height) \nVALUES \n (:chain!, :action!, :data!, :block_height!)"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO example_table + * (chain, action, data, block_height) + * VALUES + * (:chain!, :action!, :data!, :block_height!) + * ``` + */ +export const insertData = new PreparedQuery(insertDataIR); + + +/** 'GetDataByChain' parameters type */ +export interface IGetDataByChainParams { + chain: string; + limit: NumberOrString; + offset: NumberOrString; +} + +/** 'GetDataByChain' return type */ +export interface IGetDataByChainResult { + action: string; + block_height: number; + chain: string; + data: string; + id: number; +} + +/** 'GetDataByChain' query type */ +export interface IGetDataByChainQuery { + params: IGetDataByChainParams; + result: IGetDataByChainResult; +} + +const getDataByChainIR: any = {"usedParamSet":{"chain":true,"limit":true,"offset":true},"params":[{"name":"chain","required":true,"transform":{"type":"scalar"},"locs":[{"a":42,"b":48}]},{"name":"limit","required":true,"transform":{"type":"scalar"},"locs":[{"a":83,"b":89}]},{"name":"offset","required":true,"transform":{"type":"scalar"},"locs":[{"a":98,"b":105}]}],"statement":"SELECT * FROM example_table\nWHERE chain = :chain!\nORDER BY block_height DESC\nLIMIT :limit!\nOFFSET :offset!"}; + +/** + * Query generated from SQL: + * ``` + * SELECT * FROM example_table + * WHERE chain = :chain! + * ORDER BY block_height DESC + * LIMIT :limit! + * OFFSET :offset! + * ``` + */ +export const getDataByChain = new PreparedQuery(getDataByChainIR); diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/.gitignore.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/.gitignore.rename new file mode 100644 index 000000000..aebb8e57c --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/.gitignore.rename @@ -0,0 +1,3 @@ +node_modules +min.js +min.js.map \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-bitcoin.js.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-bitcoin.js.rename new file mode 100644 index 000000000..721d7faab --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-bitcoin.js.rename @@ -0,0 +1,103 @@ +import * as bitcoin from "bitcoinjs-lib"; +import * as bitcoinMessage from "bitcoinjs-message"; +import ecc from "@bitcoinerlab/secp256k1"; +import { ECPairFactory } from "ecpair"; +const ECPair = ECPairFactory(ecc); + +const target = { + address: "bcrt1qfv6m6l5s6cgda09yr5nd8rnufkaz59d3aquq03", + privateKey: "cPNCP9RTgYu6aqw4cTFQgrrTKkz6oJPUnxuYeaDrWR5wAkDqwHjc", + privateKeyWIF: "cPNCP9RTgYu6aqw4cTFQgrrTKkz6oJPUnxuYeaDrWR5wAkDqwHjc", +}; + +export async function bitcoinBatcher() { + console.log("Running Bitcoin Batcher Test..."); + + // We use the target's private key to sign a message authorizing sending + // funds from target.address (though in regtest via batcher, this is a mock + // 'authorization' - the batcher uses its own wallet, but checks the signature against the intent). + + const payload = { + toAddress: "bcrt1qa94dntprzqdkk8aygc9takzsn8shn5fzu5vqh7", // Some random recipient + amountSats: 10000, + }; + + try { + const k = ECPair.fromWIF(target.privateKeyWIF, bitcoin.networks.regtest); + const result = await sendBitcoin(k, payload, "wait-receipt"); + + if (!result.success) { + throw new Error(`Batcher submission failed: ${result.message}`); + } + + if (result.inputsProcessed !== 1) { + throw new Error( + `Expected 1 input processed, got ${result.inputsProcessed}` + ); + } + console.log("Bitcoin batcher submission successful!"); + alert("Bitcoin batcher submission successful!"); + } catch (e) { + console.error("Bitcoin batcher test failed:", e); + alert("Bitcoin batcher test failed: " + e.message); + throw e; + } +} + +function buildBitcoinSignatureMessage(payload, timestamp) { + return `send ${payload.amountSats} sats to ${payload.toAddress} at ${timestamp}`; +} + +const BATCHER_ENDPOINT = "http://localhost:3334/send-input"; +// const ECPair = ecpair.ECPairFactory(tinysecp); + +async function sendBitcoin( + privateKeyWIF, + payload, + confirmationLevel = "no-wait", + network = bitcoin.networks.regtest, + target = "bitcoin" +) { + const keyPair = privateKeyWIF; + const { address } = bitcoin.payments.p2wpkh({ + pubkey: keyPair.publicKey, + network, + }); + + if (!address) throw new Error("Could not derive address from private key"); + + const timestamp = new Date().toISOString(); + const message = buildBitcoinSignatureMessage(payload, timestamp); + + const signature = bitcoinMessage + .sign(message, Buffer.from(keyPair.privateKey), keyPair.compressed) + .toString("base64"); + + const body = { + address, + input: JSON.stringify(payload), + signature, + timestamp, + target, + addressType: -1, + }; + + const response = await fetch(BATCHER_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: body, + confirmationLevel, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send Bitcoin transaction: ${response.statusText}` + ); + } + + return response.json(); +} diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-midnight.js.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-midnight.js.rename new file mode 100644 index 000000000..1ab957a8f --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/batcher-midnight.js.rename @@ -0,0 +1,43 @@ + +async function sendMintToBatcher( + amount /*: number | string*/, + confirmationLevel /*: string */ = "no-wait", + )/*: Promise */{ + const account = { + is_left: true, + left: { + bytes: "0x00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF", + }, + right: { + bytes: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + }; + const input = JSON.stringify({ + circuit: "mint", + args: [account, amount], + }); + const body = { + data: { + target: "midnightAdapter_unshielded_erc20", + address: "placeholderaddress", + addressType: AddressType.MIDNIGHT, + input, + timestamp: Date.now(), + }, + confirmationLevel: confirmationLevel, + }; + const response = await fetch(`${BATCHER_URL}/send-input`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const result = await response.json(); + if (response.ok) { + console.log("Mint sent to batcher successfully"); + } else { + console.error("[ERROR] Sending mint to batcher:", result); + } + return response.status; + } \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/esbuild.js.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/esbuild.js.rename new file mode 100644 index 000000000..b045fd3a9 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/esbuild.js.rename @@ -0,0 +1,19 @@ +import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; +import { build } from "esbuild"; +build({ + entryPoints: ["./index.js"], + bundle: true, + outfile: "min.js", + sourcemap: true, + loader: { + ".wasm": "binary", + }, + plugins: [ + nodeModulesPolyfillPlugin({ + globals: { + process: true, + Buffer: true, + }, + }), + ], +}); \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.html b/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.html new file mode 100644 index 000000000..d381764c4 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.html @@ -0,0 +1,158 @@ + + + + + + [projectName] + + + + +
+

Example dApp

+ +/** MIDNIGHT-BLOCK */ + +/** MIDNIGHT-BLOCK */ +/** BITCOIN-BLOCK */ + +/** BITCOIN-BLOCK */ +
+ + + +
+

Sync Protocols Status

+
Loading sync status...
+ + + + + + + + + + + + +
+ +
+

Primitives Status

+ + + + + + + + + + + + + +
Primitive NameTypeNetworkSync ProtocolDetails
+
+ +
+

Chain Data

+
+ /** AVAIL-BLOCK */ + + /** AVAIL-BLOCK */ + /** BITCOIN-BLOCK */ + + /** BITCOIN-BLOCK */ + /** CARDANO-BLOCK */ + + /** CARDANO-BLOCK */ + /** EVM-BLOCK */ + + /** EVM-BLOCK */ + /** MIDNIGHT-BLOCK */ + + /** MIDNIGHT-BLOCK */ +
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.js.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.js.rename new file mode 100644 index 000000000..c59393a76 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/index.js.rename @@ -0,0 +1,517 @@ +import { + allInjectedWallets, + PaimaEngineConfig as EffectstreamEngineConfig, + sendTransaction, + walletLogin, + WalletMode, + WalletNameMap, +} from "@paimaexample/wallets"; + +import { hardhat } from "viem/chains"; +import mqtt from "mqtt"; +import { bitcoinBatcher } from "./batcher-bitcoin.js"; + +export const effectStreamConfig = new EffectstreamEngineConfig( + "", + "mainEvmRPC", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + hardhat, + undefined, + undefined, + false, +); + +let wallet = null; + +// Helper to map chainId to wallet type name +const chainIdToWalletType = (chainId) => { + return WalletNameMap[chainId] || `Unknown (${chainId})`; +}; + +// Instead of a static list, we will now generate available wallets dynamically +// combining injected wallets and static local test wallets. +async function getAvailableWallets() { + const wallets = []; + + // 1. Fetch Injected Wallets + try { + // Wait a bit for wallets to inject (as seen in example) + await new Promise(resolve => setTimeout(resolve, 200)); + + const injectedWallets = await allInjectedWallets(); + + if (injectedWallets) { + for (const [modeStr, walletList] of Object.entries(injectedWallets)) { + const mode = Number(modeStr); + if (Array.isArray(walletList) && walletList.length > 0) { + for (const w of walletList) { + // Get a lower-case network type like 'evm' or 'cardano' from the wallet mode. + const networkType = (WalletNameMap[mode] || "").toLowerCase(); + + wallets.push({ + name: w.metadata.displayName, + mode, + preference: { name: w.metadata.name }, // Store preference for login + types: [networkType], + metadata: w.metadata, + // We'll attach a specific login function or handle it via generic login + isInjected: true, + }); + } + } + } + } + } catch (e) { + console.error("Failed to fetch injected wallets:", e); + } + + // 2. Add Static/Local Wallets + // "Viem Local Wallet" - emulated for now since we don't have the full Viem setup in this JS file + // exactly as the React example (which imports privateKeyToAccount etc directly). + // If we can't easily polyfill viem/accounts here without more build steps, we might skip implementation details + // or use the simple version if possible. + // For now, let's keep the structure but note it might need the viem imports if we want it to actually work + // fully locally. The original index.js didn't import privateKeyToAccount. + // We'll keep the "EVM [test error]" as it's simple. + + wallets.push({ + name: "EVM [test error]", + mode: WalletMode.EvmInjected, + preference: { name: "No wallet" }, + types: ["evm"], + metadata: { + name: "EVM Test Error", + displayName: "EVM [test error]", + }, + checkChainId: true, + }); + + return wallets; +} + +async function login(walletOption) { + // Logic adapted from handleLogin and handleConnectInjected in App.tsx + + let checkChainId = true; + // Special case for Phantom/Exodus as per example + if (walletOption.metadata && (walletOption.metadata.name === "app.phantom" || walletOption.metadata.name === "com.exodus.web3-wallet")) { + checkChainId = false; + } + // If explicitly set in option (like for test error wallet) + if (walletOption.checkChainId !== undefined) { + checkChainId = walletOption.checkChainId; + } + + const loginOptions = { + mode: walletOption.mode, + preference: walletOption.preference, + preferBatchedMode: false, + // Logic for chain: only for EVM injected/ethers + chain: (walletOption.mode === WalletMode.EvmInjected || walletOption.mode === WalletMode.EvmEthers) ? hardhat : undefined, + checkChainId: checkChainId, + }; + + console.log("Logging in with options:", loginOptions); + + const result = await walletLogin(loginOptions); + + if (!result.success) throw new Error("Cannot login: " + result.errorMessage); + wallet = { ...result.result, mode: walletOption.mode }; + return wallet; +} + +async function sendTransactionPaimaL2(input) { + if (!wallet) throw new Error("Wallet not connected"); + const result = await sendTransaction( + wallet, + ["my_action_name", input ?? "no-text"], + effectStreamConfig, + ); + return result; +} + +// Expose functions to window +window.effectstream = { + login, + sendTransactionPaimaL2, + getAvailableWallets, // Now exposes the combined list getter + bitcoinBatcher, +}; + +// UI Logic moved from index.html +document.addEventListener('DOMContentLoaded', () => { + const configDisplay = document.getElementById('config-display'); + const syncStatusDiv = document.getElementById('sync-status'); + const syncTable = document.getElementById('sync-table'); + const syncTableBody = document.getElementById('sync-table-body'); + const primitivesTableBody = document.getElementById('primitives-table-body'); + const mqttStatusSpan = document.getElementById('mqtt-status'); + + // Chain Data UI Elements + const chainButtons = document.querySelectorAll('.chain-btn'); + const chainTable = document.getElementById('chain-table'); + const chainTableBody = document.getElementById('chain-table-body'); + const chainDataStatus = document.getElementById('chain-data-status'); + + if (chainButtons) { + chainButtons.forEach(btn => { + btn.addEventListener('click', () => fetchChainData(btn.dataset.chain)); + }); + } + + async function fetchChainData(chain) { + if (!chainTable || !chainTableBody || !chainDataStatus) return; + + chainDataStatus.textContent = `Loading data for ${chain}...`; + chainTable.style.display = 'none'; + chainTableBody.innerHTML = ''; + + try { + const response = await fetch(`http://localhost:9999/api/table/${chain}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + + if (data && data.length > 0) { + renderChainTable(data); + chainTable.style.display = 'table'; + chainDataStatus.textContent = ''; + } else { + chainDataStatus.textContent = 'No data found for this chain.'; + } + + } catch (error) { + console.error('Error fetching chain data:', error); + chainDataStatus.textContent = `Error: ${error.message}`; + } + } + + function renderChainTable(data) { + chainTableBody.innerHTML = ''; + data.forEach(item => { + const row = document.createElement('tr'); + + // Format data object for display if it's a stringified JSON + let displayData = item.data; + try { + // formatting to be prettier + const parsed = JSON.parse(item.data); + displayData = `
${JSON.stringify(parsed, null, 2)}
`; + } catch (e) { + // keep as is if not valid json + } + + row.innerHTML = ` + ${item.id} + ${item.chain} + ${item.action} + ${displayData} + ${item.block_height} + `; + chainTableBody.appendChild(row); + }); + } + + // Wallet UI Elements + const connectWalletBtn = document.getElementById('connect-wallet-btn'); + const sendBitcoinBtn = document.getElementById('send-bitcoin-btn'); + const walletModal = document.getElementById('wallet-modal'); + const closeModal = document.getElementById('close-modal'); + const walletList = document.getElementById('wallet-list'); + const loadingWallets = document.getElementById('loading-wallets'); + const walletInfoContainer = document.getElementById('wallet-info-container'); + const walletDetails = document.getElementById('wallet-details'); + + let knownProtocols = {}; // Map name -> row element + + // Wallet Logic + if (connectWalletBtn) { + connectWalletBtn.addEventListener('click', () => { + walletModal.style.display = 'block'; + renderWalletList(); + }); + } + + if (sendBitcoinBtn) { + sendBitcoinBtn.addEventListener('click', async () => { + try { + await bitcoinBatcher(); + } catch (err) { + console.error("Error in bitcoin batcher:", err); + } + }); + } + + if (closeModal) { + closeModal.addEventListener('click', () => { + walletModal.style.display = 'none'; + }); + } + + window.onclick = (event) => { + if (event.target == walletModal) { + walletModal.style.display = 'none'; + } + }; + + async function renderWalletList() { + walletList.innerHTML = ''; + loadingWallets.style.display = 'block'; + + if (window.effectstream && window.effectstream.getAvailableWallets) { + try { + const wallets = await window.effectstream.getAvailableWallets(); + loadingWallets.style.display = 'none'; + + if (wallets && wallets.length > 0) { + wallets.forEach(wallet => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.className = 'wallet-btn'; + + // Richer button content with icon if available + let iconHtml = ''; + if (wallet.metadata && wallet.metadata.icon) { + iconHtml = ``; + } else { + const initial = wallet.metadata && wallet.metadata.displayName ? wallet.metadata.displayName.charAt(0) : '?'; + iconHtml = `${initial}`; + } + + btn.innerHTML = ` +
+ ${iconHtml} +
+ ${wallet.name} + ${wallet.types.join(', ')} +
+
+ `; + + btn.onclick = () => handleLogin(wallet); + li.appendChild(btn); + walletList.appendChild(li); + }); + } else { + walletList.innerHTML = '
  • No wallets found.
  • '; + } + } catch (e) { + loadingWallets.style.display = 'none'; + walletList.innerHTML = `
  • Error loading wallets: ${e.message}
  • `; + } + } else { + loadingWallets.style.display = 'none'; + walletList.innerHTML = '
  • Wallet SDK not loaded.
  • '; + } + } + + async function handleLogin(walletOption) { + try { + walletModal.style.display = 'none'; + connectWalletBtn.textContent = 'Connecting...'; + + const wallet = await window.effectstream.login(walletOption); + + connectWalletBtn.textContent = 'Connected'; + walletInfoContainer.style.display = 'block'; + walletDetails.textContent = JSON.stringify({ + address: wallet.walletAddress, + metadata: wallet.metadata, + mode: wallet.mode + }, null, 2); + + } catch (err) { + console.error(err); + alert('Login failed: ' + err.message); + connectWalletBtn.textContent = 'Connect Wallet'; + } + } + + // Configuration Fetching (Existing) + async function fetchConfig() { + try { + const response = await fetch('http://localhost:9999/config'); + const config = await response.json(); + + // Display Config (Hidden but available) + if (configDisplay) configDisplay.innerHTML = `
    ${JSON.stringify(config, null, 2)}
    `; + + // Initialize Tables + initializeSyncTable(config); + initializePrimitivesTable(config); + + // Start MQTT + startMqtt(); + + } catch (error) { + if (configDisplay) configDisplay.textContent = 'Error loading config: ' + error.message; + if (syncStatusDiv) syncStatusDiv.textContent = 'Error loading sync status.'; + } + } + + function initializePrimitivesTable(config) { + if (!primitivesTableBody) return; + primitivesTableBody.innerHTML = ''; + config.forEach(networkConfig => { + if (networkConfig.primitives && networkConfig.primitives.length > 0) { + networkConfig.primitives.forEach(prim => { + const p = prim.primitive; + const row = document.createElement('tr'); + + let details = ''; + if (p.contractAddress) details += `Contract: ${p.contractAddress.substring(0, 10)}...
    `; + if (p.startBlockHeight !== undefined) details += `Start Block: ${p.startBlockHeight}
    `; + if (p.scheduledPrefix) details += `Prefix: ${p.scheduledPrefix}`; + + row.innerHTML = ` + ${p.name} + ${p.type} + ${networkConfig.networkType} + ${prim.syncProtocol} + ${details} + `; + primitivesTableBody.appendChild(row); + }); + } + }); + + if (primitivesTableBody.children.length === 0) { + const row = document.createElement('tr'); + row.innerHTML = 'No primitives configured'; + primitivesTableBody.appendChild(row); + } + } + + function initializeSyncTable(config) { + if (!syncStatusDiv || !syncTable) return; + syncStatusDiv.style.display = 'none'; + syncTable.style.display = 'table'; + + // Extract unique sync protocols + const syncProtocols = config.map(c => c.syncProtocol).filter(p => p && p.name); + + syncProtocols.forEach(protocol => { + const row = document.createElement('tr'); + row.id = `row-${protocol.name}`; + row.innerHTML = ` + ${protocol.name} + ${protocol.type} + Waiting... + - + `; + syncTableBody.appendChild(row); + knownProtocols[protocol.name] = { + blockEl: row.querySelector(`#block-${protocol.name}`), + timeEl: row.querySelector(`#time-${protocol.name}`) + }; + }); + + // Add Effectstream Engine (Main) row + const mainRow = document.createElement('tr'); + mainRow.style.backgroundColor = "#e8f4fd"; + mainRow.innerHTML = ` + Effectstream Engine (Rollup) + Core + Waiting... + - + `; + // Insert at top + syncTableBody.insertBefore(mainRow, syncTableBody.firstChild); + knownProtocols['__main__'] = { + blockEl: mainRow.querySelector(`#block-__main__`), + timeEl: mainRow.querySelector(`#time-__main__`) + }; + } + + function updateProtocolStatus(name, block, timestamp) { + // Normalize name if needed (some might come as 'mainEvmRPC' etc) + // If we don't know it, maybe add it dynamically? For now only update known. + + if (knownProtocols[name]) { + knownProtocols[name].blockEl.textContent = block; + if (timestamp) { + const date = new Date(Number(timestamp) < 10000000000 ? Number(timestamp) * 1000 : Number(timestamp)); + knownProtocols[name].timeEl.textContent = date.toLocaleTimeString(); + } + } else { + console.log(Object.keys(knownProtocols)); + console.log(block); + console.warn(`Received update for unknown protocol: ${name}`); + } + } + + function startMqtt() { + // Using ws://localhost:8883 as seen in event-connect.ts + // Note: browser uses WS, node uses MQTT protocol over TCP usually, but here it seems to be WS. + // Check if mqtt is defined (it's a global from the script tag in HTML) + if (typeof mqtt === 'undefined') { + console.error("MQTT library not loaded"); + return; + } + + const client = mqtt.connect('ws://localhost:8883'); + + client.on('connect', () => { + if (mqttStatusSpan) { + mqttStatusSpan.textContent = 'Connected'; + mqttStatusSpan.className = 'status-connected'; + } + console.log('MQTT Connected'); + + // Subscribe to topics + // node/block/+ -> RollupBlock + client.subscribe('node/block/+', (err) => { + if (err) console.error('Subscribe error node/block/+:', err); + }); + + // node/chain/+/block/+ -> SyncChains + client.subscribe('node/chain/+/block/+', (err) => { + if (err) console.error('Subscribe error node/chain/+/block/+:', err); + }); + }); + + client.on('message', (topic, message) => { + try { + const payload = JSON.parse(message.toString()); + + const parts = topic.split('/'); + + // Parse topic to determine type + // node/block/{block} + if (topic.startsWith('node/block/')) { + // RollupBlock: block is in topic + const blockHeight = parts[2]; + updateProtocolStatus('__main__', blockHeight, payload.timestamp); + } + // node/chain/{chain}/block/{block} + else if (topic.startsWith('node/chain/')) { + // SyncChains: chain and block are in topic + const chain = parts[2]; + const blockHeight = parts[4]; + updateProtocolStatus(chain, blockHeight, null); + } + + } catch (e) { + console.error('Error processing message:', e); + } + }); + + client.on('error', (err) => { + console.error('MQTT Error:', err); + if (mqttStatusSpan) { + mqttStatusSpan.textContent = 'Error'; + mqttStatusSpan.className = 'status-disconnected'; + } + }); + + client.on('close', () => { + if (mqttStatusSpan) { + mqttStatusSpan.textContent = 'Disconnected'; + mqttStatusSpan.className = 'status-disconnected'; + } + }); + } + + fetchConfig(); +}); diff --git a/packages/build-tools/template-generator/src/packages/templates/frontend-min/package.json.rename b/packages/build-tools/template-generator/src/packages/templates/frontend-min/package.json.rename new file mode 100644 index 000000000..52fcf50ad --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/frontend-min/package.json.rename @@ -0,0 +1,27 @@ +{ + "name": "[projectName]-frontend-esbuild", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", + "@paimaexample/wallets": "[EFFECTSTREAM-VERSION]", + "bitcoinjs-lib": "^7.0.0", + "bitcoinjs-message": "^2.2.0", + "ecpair": "^3.0.0", + "mqtt": "^5.14.1", + "tiny-secp256k1": "^2.2.4", + "viem": "^2.37.5" + }, + "devDependencies": { + "esbuild": "0.25.9", + "esbuild-plugins-node-modules-polyfill": "^1.7.1" + }, + "workspaces": [] +} diff --git a/packages/build-tools/template-generator/src/packages/templates/node/package.json.rename b/packages/build-tools/template-generator/src/packages/templates/node/package.json.rename new file mode 100644 index 000000000..7a51a436f --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/node/package.json.rename @@ -0,0 +1,7 @@ +{ + "dependencies": { + "@paimaexample/explorer": "0.3.104", + "http-server": "^14.1.1" + }, + "devDependencies": {} +} \ No newline at end of file diff --git a/packages/build-tools/template-generator/src/packages/templates/node/scripts/start.ts.rename b/packages/build-tools/template-generator/src/packages/templates/node/scripts/start.ts.rename new file mode 100644 index 000000000..525e043c6 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/node/scripts/start.ts.rename @@ -0,0 +1,120 @@ +import { OrchestratorConfig, start } from "@paimaexample/orchestrator"; +import { ComponentNames } from "@paimaexample/log"; +import { Value } from "@sinclair/typebox/value"; +import { launchCardano } from "@paimaexample/orchestrator/start-cardano"; +import { launchEvm } from "@paimaexample/orchestrator/start-evm"; +import { launchMidnight } from "@paimaexample/orchestrator/start-midnight"; +import { launchAvail } from "@paimaexample/orchestrator/start-avail"; +import { launchBitcoin } from "@paimaexample/orchestrator/start-bitcoin"; + +const isCardano = [isCardano]; +const isAvail = [isAvail]; +const isMidnight = [isMidnight]; +const isEvm = [isEvm]; +const isBitcoin = [isBitcoin]; + +const customProcesses = [ + /** STANDALONE-FRONTEND-BLOCK */ + { + name: "install-frontend", + command: "npm", + cwd: "../../../frontend/standalone", + args: ["install"], + waitToExit: true, + type: "system-dependency", + dependsOn: [], + }, + { + name: "build-frontend", + command: "node", + cwd: "../../../frontend/standalone", + args: ["esbuild.js"], + waitToExit: true, + type: "system-dependency", + dependsOn: ["install-frontend"], + }, + { + name: "serve-frontend", + command: "npx", + cwd: "../../../frontend/standalone", + args: ["http-server", ".", "-p", "8080"], + waitToExit: false, + link: "http://127.0.0.1:8080", + type: "system-dependency", + dependsOn: ["build-frontend"], + logs: 'none', + }, + /** STANDALONE-FRONTEND-BLOCK */ + + /** EXPLORER-BLOCK */ + { + name: "explorer", + args: ["run", "-A", "--unstable-detect-cjs", "@paimaexample/explorer"], + waitToExit: false, + type: "system-dependency", + link: "http://localhost:10590", + stopProcessAtPort: [10590], + }, + /** EXPLORER-BLOCK */ + + /** BATCHER-BLOCK */ + { + name: "batcher", + args: ["task", "-f", "@[scope]/batcher", "start"], + waitToExit: false, + type: "system-dependency", + link: "http://localhost:3334", + stopProcessAtPort: [3334], + dependsOn: [ + isEvm ? ComponentNames.DEPLOY_EVM_CONTRACTS : false, + isMidnight ? ComponentNames.MIDNIGHT_CONTRACT : false, + ].filter(Boolean), + }, + /** BATCHER-BLOCK */ +] + +const config = Value.Parse(OrchestratorConfig, { + // Launch system processes + packageName: "jsr:@paimaexample", + processes: { + [ComponentNames.TMUX]: true, + [ComponentNames.TUI]: true, + // Launch Dev DB & Collector + [ComponentNames.EFFECTSTREAM_PGLITE]: true, + [ComponentNames.COLLECTOR]: true, + }, + + // Launch my processes + processesToLaunch: [ + /** EVM-BLOCK */ + ...launchEvm("@[scope]/evm-contracts"), + /** EVM-BLOCK */ + + /** MIDNIGHT-BLOCK */ + ...launchMidnight("@[scope]/midnight-contracts"), + /** MIDNIGHT-BLOCK */ + + /** CARDANO-BLOCK */ + ...launchCardano("@[scope]/cardano-contracts"), + /** CARDANO-BLOCK */ + + /** AVAIL-BLOCK */ + ...launchAvail("@[scope]/avail-contracts"), + /** AVAIL-BLOCK */ + + /** BITCOIN-BLOCK */ + ...launchBitcoin("@[scope]/bitcoin-contracts"), + /** BITCOIN-BLOCK */ + + ...customProcesses, + ], +}); + +if (Deno.env.get("EFFECTSTREAM_STDOUT")) { + config.logs = "stdout"; + config.processes[ComponentNames.TMUX] = false; + config.processes[ComponentNames.TUI] = false; + config.processes[ComponentNames.COLLECTOR] = false; +} + +await start(config); diff --git a/packages/build-tools/template-generator/src/packages/templates/node/src/api.ts.rename b/packages/build-tools/template-generator/src/packages/templates/node/src/api.ts.rename new file mode 100644 index 000000000..a2fefb5a7 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/node/src/api.ts.rename @@ -0,0 +1,41 @@ +import { type Static, Type } from "@sinclair/typebox"; +import { runPreparedQuery } from "@paimaexample/db"; +import { getDataByChain } from "@[scope]/database"; +import type { Pool } from "pg"; +import type { StartConfigApiRouter } from "@paimaexample/runtime"; +import type fastify from "fastify"; + +/** + * Example for User Defined API Routes. + * Register custom endpoints here. + * @param server - The Fastify instance. + * @param dbConn - The database connection. + */ +export const apiRouter: StartConfigApiRouter = async function ( + server: fastify.FastifyInstance, + dbConn: Pool, +): Promise { + // Definition of API Inputs and Outputs. + // These definition build the OpenAPI documentation. + // And allow to have type safety for the API Endpoints. + const ParamsSchema = Type.Object({ + chain: Type.String(), + }); + const ResponseSchema = Type.Array(Type.Object({ + action: Type.String(), + block_height: Type.Number(), + chain: Type.String(), + data: Type.String(), + id: Type.Number(), + })); + + server.get<{ + Params: Static; + Reply: Static; + }>("/api/table/:chain", async (request, reply) => { + const { chain } = request.params; + const result = await runPreparedQuery(getDataByChain.run({ chain, limit: 10, offset: 0 }, dbConn), "getDataByChain"); + reply.send(result); + }); + +}; diff --git a/packages/build-tools/template-generator/src/packages/templates/node/src/main.ts.rename b/packages/build-tools/template-generator/src/packages/templates/node/src/main.ts.rename new file mode 100644 index 000000000..83f98f189 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/node/src/main.ts.rename @@ -0,0 +1,41 @@ +// NOTE & TODO: +// Importing "@midnight-ntwrk/onchain-runtime" here is a workaround. +// Loading this package in a dependency makes the onchain-runtime wasm +// fail in runtime when trying to parse the state. +// The next line is so that the wasm is loaded and not optimized away. +import { NetworkId } from "@midnight-ntwrk/onchain-runtime"; +NetworkId.Undeployed; + + +import { init, start } from "@paimaexample/runtime"; +import { main, suspend } from "effection"; +import { localhostConfig } from "@[scope]/data-types/localhostConfig"; +import { + type SyncProtocolWithNetwork, + toSyncProtocolWithNetwork, + withEffectstreamStaticConfig, +} from "@paimaexample/config"; +import { migrationTable } from "@[scope]/database"; +import { gameStateTransitions } from "./state-machine.ts"; +import { apiRouter } from "./api.ts"; +import { grammar } from "@[scope]/data-types/grammar"; + +main(function* () { + yield* init(); + console.log("Starting Paima Engine Node"); + + yield* withEffectstreamStaticConfig(localhostConfig, function* () { + yield* start({ + appName: "[scope]", + appVersion: "0.3.21", + syncInfo: toSyncProtocolWithNetwork(localhostConfig), + gameStateTransitions, + migrations: migrationTable, + apiRouter, + grammar, + userDefinedPrimitives: {}, + }); + }); + + yield* suspend(); +}); diff --git a/packages/build-tools/template-generator/src/packages/templates/node/src/state-machine.ts.rename b/packages/build-tools/template-generator/src/packages/templates/node/src/state-machine.ts.rename new file mode 100644 index 000000000..2c5657fe9 --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/node/src/state-machine.ts.rename @@ -0,0 +1,55 @@ +import { PaimaSTM } from "@paimaexample/sm"; +import { grammar } from "@[scope]/data-types/grammar"; +import type { BaseStfInput, BaseStfOutput } from "@paimaexample/sm"; +import type { StartConfigGameStateTransitions } from "@paimaexample/runtime"; +import { type SyncStateUpdateStream, World } from "@paimaexample/coroutine"; +/** EVM-BLOCK */ +import { contractAddressesEvmMain } from "@[scope]/evm-contracts"; +/** EVM-BLOCK */ + +import { insertData } from "@[scope]/database"; + +const stm = new PaimaSTM(grammar); +/** MIDNIGHT-BLOCK */ +const decodeToByteString = (x: { [key: string]: number }): string => + Array(Object.keys(x).length) + .fill(0) + .map((_,i)=>x[i]) + .join('') + .trim(); +/** MIDNIGHT-BLOCK */ + + +/** MIDNIGHT-STM-BLOCK */ + +/** EVM-STM-BLOCK */ + +/** AVAIL-STM-BLOCK */ + +/** CARDANO-STM-BLOCK */ + +/** BITCOIN-STM-BLOCK */ + +// stm.finalize(); // this avoids people dynamically calling stm.addStateTransition after initialization + +/** + * This function allows you to route between different State Transition Functions + * based on block height. In other words when a new update is pushed for your game + * that includes new logic, this router allows your game node to cleanly maintain + * backwards compatibility with the old history before the new update came into effect. + * @param blockHeight - The block height to process the game state transitions for. + * @param input - The input to process the game state transitions for. + * @returns The result of the game state transitions. + */ +export const gameStateTransitions: StartConfigGameStateTransitions = function* ( + blockHeight: number, + input: BaseStfInput +): SyncStateUpdateStream { + if (blockHeight >= 0) { + yield* stm.processInput(input); + } else { + yield* stm.processInput(input); + } + return; +}; + diff --git a/packages/build-tools/template-generator/src/packages/templates/readme.md b/packages/build-tools/template-generator/src/packages/templates/readme.md new file mode 100644 index 000000000..c482bc79a --- /dev/null +++ b/packages/build-tools/template-generator/src/packages/templates/readme.md @@ -0,0 +1,14 @@ +# Templates + +The files in this folder are used to generate the final source code. + +### Common replacements + +- `[scope]`: The name of the project. + +### Code blocks + +Blocks are conditionally included in the generated code based on the chains selected in the project configuration. + +- `EVM-BLOCK`: The code block for the EVM chain. +- `MIDNIGHT-BLOCK`: The code block for the Midnight chain. diff --git a/packages/build-tools/template-generator/src/project-generator.ts b/packages/build-tools/template-generator/src/project-generator.ts new file mode 100644 index 000000000..3d2c36490 --- /dev/null +++ b/packages/build-tools/template-generator/src/project-generator.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { MainReadmeFile } from './file-types/readme-file.ts'; +import { RootDenoJsonFile } from './file-types/deno-json-root-file.ts'; +import { GitignoreFile } from './file-types/gitignore-file.ts'; +import { RootPackageJsonFile } from './file-types/package-json-root-file.ts'; +import { PatchFile } from './file-types/patch-file.ts'; +import { TemplateOptions } from './options.ts'; +import { PackageManager } from './package-manager.ts'; +import { PackageInfo } from './packages/abstract-package.ts'; + +export class ProjectGenerator { + constructor(private options: TemplateOptions) {} + + public async generate(): Promise { + console.log("Generating project with options:", this.options); + const projectPath = path.join(this.options.folderPath, this.options.projectName); + + // We don't really have a "root" package, so we'll just generate the files + // and let the package manager handle the real packages. + await this.createRootFiles(projectPath); + + return await this.createPackages(projectPath); + } + + private async createRootFiles(projectPath: string): Promise { + await new RootDenoJsonFile(path.join(projectPath, 'deno.json'), this.options.projectName, this.options.chains, this.options.frontends[0]).write(); + await new MainReadmeFile(path.join(projectPath, 'README.md'), this.options.projectName, this.options.chains).write(); + await new GitignoreFile(path.join(projectPath, '.gitignore')).write(); + await new RootPackageJsonFile(path.join(projectPath, 'package.json')).write(); + await new PatchFile(path.join(projectPath, 'patch.sh')).write(0o755); + } + + private async createPackages(projectPath: string): Promise { + const packageManager = new PackageManager(projectPath, this.options); + return await packageManager.generate(); + } +} diff --git a/packages/build-tools/template-generator/src/template-generator.tsx b/packages/build-tools/template-generator/src/template-generator.tsx new file mode 100644 index 000000000..899ba52ca --- /dev/null +++ b/packages/build-tools/template-generator/src/template-generator.tsx @@ -0,0 +1,425 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { render, Box, Text, useApp, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import SelectInput from 'ink-select-input'; +import { ProjectGenerator } from './project-generator.ts'; +import { type TemplateOptions, ALL_CHAINS, CONTRACTS_BY_CHAIN, ALL_FRONTENDS, type Chain, type Contract, type Frontend, DEFAULT_DEV_OPTIONS } from './options.ts'; +import path from 'node:path'; +import process from 'node:process'; +import fs from 'node:fs'; + + +const sanitizeProjectName = (name: string) => { + return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); +}; + +const Banner = () => ( + + ========================================= + Effectstream Template Generator + ========================================= + +); + + +type Step = + | 'projectName' + | 'confirmProjectName' + | 'selectFolder' + | 'selectChains' + | 'selectContracts' + | 'selectFrontend' + | 'selectDevOptions' + | 'summary'; + + +const CustomMultiSelect = ({ items, selected, onToggle }: { items: {label: string, value: string}[], selected: Set, onToggle: (value: string) => void }) => { + const [focusIndex, setFocusIndex] = useState(0); + + useInput((input, key) => { + if (key.upArrow) { + setFocusIndex(Math.max(0, focusIndex - 1)); + } else if (key.downArrow) { + setFocusIndex(Math.min(items.length - 1, focusIndex + 1)); + } else if (input === ' ') { + const item = items[focusIndex]; + if (!item) return; + onToggle(item.value); + } + }); + + return ( + + {items.map((item, index) => ( + + {focusIndex === index ? '> ' : ' '} + [{selected.has(item.value) ? 'x' : ' '}] {item.label} + + ))} + + ); +}; + + +class OptionsSelector { + private options: TemplateOptions | undefined; + + private SelectionComponent = ({ onComplete }: { onComplete: (options: TemplateOptions) => void }) => { + const [step, setStep] = useState('projectName'); + const [options, setOptions] = useState>({ + projectName: '', + folderPath: process.env.TEMPLATE_PATH || '.', + chains: [], + contracts: {}, + frontends: [], + devOptions: DEFAULT_DEV_OPTIONS, + }); + const [contractSelectionChainIndex, setContractSelectionChainIndex] = useState(0); + const { exit } = useApp(); + + const handleProjectNameSubmit = (projectName: string) => { + const nameToConfirm = projectName.trim() === '' ? "My Project" : projectName; + setOptions(prev => ({ ...prev, projectName: nameToConfirm })); + setStep('confirmProjectName'); + }; + + const handleProjectNameConfirm = (item: { value: 'yes' | 'no' }) => { + if (item.value === 'yes') { + setOptions(prev => ({ ...prev, projectName: sanitizeProjectName(prev.projectName!) })); + setStep('selectFolder'); + } else { + setOptions(prev => ({ ...prev, projectName: '' })); + setStep('projectName'); + } + }; + + const handleFolderPathSubmit = (folderPath: string) => { + setOptions(prev => ({ ...prev, folderPath })); + setStep('selectChains'); + }; + + const handleChainsSubmit = () => { + if (options.chains && options.chains.length > 0) { + setStep('selectContracts'); + } else { + setStep('selectFrontend'); + } + }; + + const handleContractsSubmit = () => { + if (contractSelectionChainIndex + 1 < options.chains!.length) { + setContractSelectionChainIndex(i => i + 1); + } else { + setStep('selectFrontend'); + } + }; + + const handleFrontendsSubmit = () => { + setStep('selectDevOptions'); + }; + + const handleDevOptionsSubmit = () => { + setStep('summary'); + }; + + const handleConfirm = (item: { value: 'yes' | 'no' }) => { + if (item.value === 'yes') { + onComplete(options as TemplateOptions); + exit(); + } else { + setStep('projectName'); + setOptions({ + projectName: '', + folderPath: process.env.TEMPLATE_PATH || '.', + chains: [], + contracts: {}, + frontends: [], + devOptions: DEFAULT_DEV_OPTIONS, + }); + setContractSelectionChainIndex(0); + } + }; + + let currentStepComponent; + switch (step) { + case 'projectName': + currentStepComponent = ( + + Enter project name: + setOptions(prev => ({...prev, projectName: value}))} + onSubmit={handleProjectNameSubmit} + /> + + ); + break; + case 'confirmProjectName': { + const sanitizedName = sanitizeProjectName(options.projectName!); + currentStepComponent = ( + + The package-friendly name will be: "{sanitizedName}". Is this ok? + + + ); + break; + } + case 'selectFolder': + currentStepComponent = ( + + Enter destination folder (default: current directory): + setOptions(prev => ({...prev, folderPath: value}))} + onSubmit={handleFolderPathSubmit} + /> + + ); + break; + case 'selectChains': + currentStepComponent = ( + + Select chains (press space to select, enter to submit): + { + const newChains = new Set(options.chains); + if (newChains.has(chain as Chain)) { + newChains.delete(chain as Chain); + } else { + newChains.add(chain as Chain); + } + setOptions(prev => ({ ...prev, chains: Array.from(newChains) })); + }} + /> + + + ); + break; + case 'selectContracts': { + const currentChain = options.chains![contractSelectionChainIndex]; + currentStepComponent = ( + + Select contracts for {currentChain} (press space to select, enter to submit): + { + const newContracts = new Set(options.contracts![currentChain]); + if (newContracts.has(contract as Contract)) { + newContracts.delete(contract as Contract); + } else { + newContracts.add(contract as Contract); + } + setOptions(prev => ({ + ...prev, + contracts: { + ...prev.contracts, + [currentChain]: Array.from(newContracts), + }, + })); + }} + /> + + + ); + break; + } + case 'selectFrontend': + currentStepComponent = ( + + Select frontend frameworks (optional): + { + const newFrontends = new Set(options.frontends); + if (newFrontends.has(frontend as Frontend)) { + newFrontends.delete(frontend as Frontend); + } else { + newFrontends.add(frontend as Frontend); + } + setOptions(prev => ({ ...prev, frontends: Array.from(newFrontends) })); + }} + /> + + + ); + break; + case 'selectDevOptions': { + currentStepComponent = ( + + Select dev options: + value) + .map(([key]) => key === 'inMemoryDb' ? 'in-memory-db' : 'use-batcher') + )} + onToggle={(option) => { + setOptions(prev => ({ + ...prev, + devOptions: { + ...prev.devOptions, + inMemoryDb: option === 'in-memory-db' ? !prev.devOptions!.inMemoryDb : prev.devOptions!.inMemoryDb, + useBatcher: option === 'use-batcher' ? !prev.devOptions!.useBatcher : prev.devOptions!.useBatcher, + }, + })); + }} + /> + + + ); + break; + } + case 'summary': + currentStepComponent = ( + + Summary: + Project Name: {options.projectName!} + Destination: {path.resolve(process.cwd(), options.folderPath!, options.projectName!)} + Chains: {options.chains!.join(', ')} + {options.chains!.map(chain => ( + + Contracts for {chain}: {options.contracts![chain]?.join(', ') || 'none'} + + ))} + Frontends: {options.frontends!.join(', ') || 'none'} + In-memory DB: {options.devOptions!.inMemoryDb ? 'Yes' : 'No'} + Use Batcher: {options.devOptions!.useBatcher ? 'Yes' : 'No'} + Generate project? + + + ); + break; + default: + currentStepComponent = null; + } + + return ( + + + {currentStepComponent} + + ); + } + + public getOptions(): Promise { + return new Promise((resolve) => { + const onComplete = (options: TemplateOptions) => { + this.options = options; + resolve(options); + }; + + render(); + }); + } +} + +async function main() { + let options: TemplateOptions; + const configFile = process.env.TEMPLATE_CONFIG_FILE; + const allOptionsFile = process.env.TEMPLATE_CONFIG_FILE_ALL; + const allFastOptionsFile = process.env.TEMPLATE_CONFIG_FILE_ALL_FAST; + + if (allOptionsFile || allFastOptionsFile) { + + console.log(`Loading all options because ${allOptionsFile ? 'TEMPLATE_CONFIG_FILE_ALL' : 'TEMPLATE_CONFIG_FILE_ALL_FAST'} is set.`); + + const allChains = ALL_CHAINS.map(c => c.value as Chain); + const allContracts: TemplateOptions['contracts'] = {}; + + for (const chain of allChains) { + if (CONTRACTS_BY_CHAIN[chain]) { + if (allFastOptionsFile) { + allContracts[chain] = CONTRACTS_BY_CHAIN[chain].map(c => c.value as Contract).slice(0, 1); + } else { + allContracts[chain] = CONTRACTS_BY_CHAIN[chain].map(c => c.value as Contract); + } + } + } + + options = { + projectName: 'my-project-all', + folderPath: process.env.TEMPLATE_PATH || '.', + chains: allChains, + contracts: allContracts, + frontends: ALL_FRONTENDS.map(f => f.value as Frontend), + devOptions: { + inMemoryDb: true, + useBatcher: true, + }, + }; + + options.projectName = sanitizeProjectName(options.projectName); + + } else if (configFile) { + console.log(`Loading configuration from: ${configFile}`); + try { + if (!fs.existsSync(configFile)) { + console.error(`Configuration file not found: ${configFile}`); + process.exit(1); + } + + const configContent = fs.readFileSync(configFile, 'utf-8'); + const configFromFile = JSON.parse(configContent) as Partial; + + const defaultOptions = { + projectName: 'My Project', + folderPath: process.env.TEMPLATE_PATH || '.', + chains: [], + contracts: {}, + frontends: [], + devOptions: DEFAULT_DEV_OPTIONS, + }; + + options = { + ...defaultOptions, + ...configFromFile, + devOptions: { + ...defaultOptions.devOptions, + ...(configFromFile.devOptions || {}), + }, + }; + + options.projectName = sanitizeProjectName(options.projectName); + + } catch (error) { + console.error(`Error processing configuration file: ${configFile}`); + console.error(error); + process.exit(1); + } + } else { + const selector = new OptionsSelector(); + options = await selector.getOptions(); + } + + const generator = new ProjectGenerator(options); + const createdPackages = await generator.generate(); + + console.log(`\nProject ${options.projectName} generated successfully!`); + + if (createdPackages && createdPackages.length > 0) { + const longestName = Math.max(...createdPackages.map(p => p.name.length)); + const longestPath = Math.max(...createdPackages.map(p => p.path.length)); + + const header = `| ${'Package'.padEnd(longestName)} | ${'Path'.padEnd(longestPath)} |`; + const separator = `|-${'-'.repeat(longestName)}-|-${'-'.repeat(longestPath)}-|`; + + console.log('\nCreated packages:'); + console.log(header); + console.log(separator); + createdPackages.forEach(pkg => { + console.log(`| ${pkg.name.padEnd(longestName)} | ${pkg.path.padEnd(longestPath)} |`); + }); + } +} + +main(); diff --git a/packages/chains/avail-contracts/src/scaffold/mod.ts b/packages/chains/avail-contracts/src/scaffold/mod.ts new file mode 100644 index 000000000..3b2ce0f6c --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/mod.ts @@ -0,0 +1,2 @@ +export * from './scaffold-avail.ts'; +export * from './scaffold-options.ts'; diff --git a/packages/chains/avail-contracts/src/scaffold/scaffold-avail.ts b/packages/chains/avail-contracts/src/scaffold/scaffold-avail.ts new file mode 100644 index 000000000..600ec0b89 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/scaffold-avail.ts @@ -0,0 +1,176 @@ +import * as path from "jsr:@std/path"; + +/* + * This module is responsible for scaffolding a new project. + */ + +// Copy files +export async function copyFiles( + sourceDir: string, + targetDir: string, + config: { + replacements?: Record, + codeBlocks?: Record, + codeInsertions?: Record, + replaceFileNames?: Record, + } = {}, +): Promise { + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(".rename", ""); + if (config.replaceFileNames?.[finalName]) { + finalName = config.replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(config.codeBlocks || {})) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(config.codeInsertions || {})) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(config.replacements || {})) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// Get current directory - this has to be JSR compatible. +function currentDir(): string { + return path.dirname(path.fromFileUrl(import.meta.url)); +} + +export async function scaffoldAvailProject( + targetFolder: string, + packageName: string, + version: string +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/avail-contracts`; + + const folders = [ + [""], + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles(path.join( + currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), + { + replacements: { + "scope": packageName, + "EFFECTSTREAM-VERSION": version + } + } + ); + } + + return { + name: fullPackageName, + path: targetFolder + } +}; + +export function availPrimitiveBlock(): string { + return ` + .addPrimitive( + (syncProtocols) => (syncProtocols as any).parallelAvail, + (network, deployments, syncProtocol) => ({ + name: "AvailContractState", + type: PrimitiveTypeAvailGeneric, + startBlockHeight: 1, + appId: readAvailApplication().appId, + applicationKey: readAvailApplication().ApplicationKey, + genesisHash: readAvailApplication().genesisHash, + stateMachinePrefix: "avail-app-state", + }) + ) + `; +} + +export function availStateMachine(contract: string): string { + return ` + stm.addStateTransition("event_avail_${contract}", function* (data) { + console.log( + "🎉 [AVAIL] Transaction receipt:", + JSON.stringify(data.parsedInput) + ); + + yield* World.resolve(insertData, { + chain: "avail", + action: "${contract}", + data: JSON.stringify(data.parsedInput), + block_height: data.blockHeight + }); + }); + `; +} + +export function availGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; +} { + return { + builtInGrammar: `"event_avail_${contract}": builtinGrammars.availGeneric,`, + customGrammar: '', + } +} + +if (import.meta.main) { + function checkInputs(args: string[]): { targetFolder: string, packageName: string, version: string } { + const targetFolder = args[0]; + const packageName = args[1]; + const version = args[2]; + if (!targetFolder) { + console.error("Target folder is required"); + Deno.exit(1); + } + if (!packageName) { + console.error("Package name is required"); + Deno.exit(1); + } + if (!version) { + console.error("Version is required"); + Deno.exit(1); + } + return { + targetFolder: targetFolder.trim(), + packageName: packageName.trim(), + version: version.trim() + }; + } + + checkInputs(Deno.args); + const { targetFolder, packageName, version } = checkInputs(Deno.args); + await scaffoldAvailProject(targetFolder, packageName, version); +} diff --git a/packages/chains/avail-contracts/src/scaffold/scaffold-options.ts b/packages/chains/avail-contracts/src/scaffold/scaffold-options.ts new file mode 100644 index 000000000..93bd0c977 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/scaffold-options.ts @@ -0,0 +1,3 @@ +export const availContractOptions = [ + { label: 'Empty Contract', value: 'empty-contract' }, +] diff --git a/packages/chains/avail-contracts/src/scaffold/template/.gitignore.rename b/packages/chains/avail-contracts/src/scaffold/template/.gitignore.rename new file mode 100644 index 000000000..9b35cef71 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/.gitignore.rename @@ -0,0 +1,2 @@ +avail_path +avail_app.json diff --git a/packages/chains/avail-contracts/src/scaffold/template/config.yml b/packages/chains/avail-contracts/src/scaffold/template/config.yml new file mode 100644 index 000000000..7e5e07e1c --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/config.yml @@ -0,0 +1,28 @@ +# Default configuration for avail-light-client +# For a full list of options, see the official Avail documentation. + +log_level="info" +http_server_host="127.0.0.1" +http_server_port=7007 + +# Secret key for libp2p keypair. Can be either set to 'seed' or to 'key'. +# If set to seed, keypair will be generated from that seed. +# If 'secret_key' is not set, a random seed will be used. +# secret_key={ seed: "avail" } + +# P2P TCP listener port (default: 37000). +port=37000 + +# WebSocket endpoint of a full node. +full_node_ws = ["ws://127.0.0.1:9955"] + +# Confidence threshold (default: 92.0). +confidence=92.0 + +# File system path where RocksDB used by the light client stores its data. +# This path is relative to the location of this config file. +avail_path="avail_path" + +# Vector of Light Client bootstrap nodes. +# This is for a local setup. Replace with public bootstraps for testnet/mainnet. +bootstraps=["/ip4/127.0.0.1/tcp/39000/p2p/12D3KooWMm1c4pzeLPGkkCJMAgFbsfQ8xmVDusg272icWsaNHWzN"] \ No newline at end of file diff --git a/packages/chains/avail-contracts/src/scaffold/template/deno.json.rename b/packages/chains/avail-contracts/src/scaffold/template/deno.json.rename new file mode 100644 index 000000000..caa147b70 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/deno.json.rename @@ -0,0 +1,18 @@ +{ + "name": "@[scope]/avail-contracts", + "version": "0.3.0", + "exports": { + ".": "./read-app.ts" + }, + "tasks": { + "avail-node:start": "deno run -A --unstable-detect-cjs npm:@paimaexample/npm-avail-node --dev --rpc-port 9955 --no-telemetry", + "avail-node:wait": "wait-on tcp:9955", + "avail-light-client:deploy": "deno task avail-light-client:clean && deno run -A --unstable-detect-cjs ./deploy.ts", + "avail-light-client:start": "deno run -A --unstable-detect-cjs npm:@paimaexample/npm-avail-light-client --config ./config.yml --app-id $AVAIL_APP_ID", + "avail-light-client:wait": "wait-on tcp:7007", + "avail-light-client:clean": "rm -rf ./avail_path" + }, + "imports": { + "avail-js-sdk": "npm:avail-js-sdk@^0.4.2" + } +} \ No newline at end of file diff --git a/packages/chains/avail-contracts/src/scaffold/template/deploy.ts.rename b/packages/chains/avail-contracts/src/scaffold/template/deploy.ts.rename new file mode 100644 index 000000000..dade54074 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/deploy.ts.rename @@ -0,0 +1,66 @@ +import { Account, Pallets, SDK } from "avail-js-sdk"; + +const sdk = await SDK.New("ws://localhost:9955/ws"); +const seed: string = Deno.env.get("SEED") ?? "//Alice"; +if (!seed) { + throw new Error("SEED environment variable is not set"); +} +const account = Account.new(seed); +const genesisHash = await sdk.client.api.rpc.chain.getBlockHash(0); +console.log("Account Address: ", account.address); +// Use a fixed key string +const ApplicationKey = "app_key_" + Date.now(); + +export async function createApplicationKey() { + // Create application key transaction + const tx = sdk.tx.dataAvailability.createApplicationKey(ApplicationKey); + console.log("Submitting transaction to create application key..."); + + // Execute and wait for inclusion + const res = await tx.executeWaitForInclusion(account, {}); + + // Check if transaction was successful + const isOk = res.isSuccessful(); + if (isOk === undefined) { + throw new Error("Cannot check if transaction was successful"); + } else if (!isOk) { + console.log("Transaction failed", res); + throw new Error("Transaction failed"); + } + + // Extract event data + if (res.events === undefined) throw new Error("No events found"); + + const event = res.events.findFirst( + Pallets.DataAvailabilityEvents.ApplicationKeyCreated, + ); + if (event === undefined) { + throw new Error("ApplicationKeyCreated event not found"); + } + + const appId = event.id; + console.log(`Application created successfully:`); + console.log(`Owner: ${event.owner}`); + console.log(`Key: ${event.keyToString()}`); + console.log(`App Id: ${appId}`); + console.log(`Transaction Hash: ${res.txHash}`); + return { appId, txHash: res.txHash }; +} + +const { appId, txHash } = await createApplicationKey(); +console.log("Transaction Hash: ", txHash.toString()); +const data = JSON.stringify({ appId, txHash, ApplicationKey, genesisHash }); +const fileName = Deno.cwd() + "/avail_app.json"; +console.log("Writing to file: ", fileName); +await Deno.writeTextFile(fileName, data); + +const child = new Deno.Command("deno", { + args: ["task", "-f", "@[scope]/avail-contracts", "avail-light-client:start"], + env: { + AVAIL_APP_ID: appId.toString(), + }, +}).spawn(); + +console.log("Light Client Started"); + +await child.status; diff --git a/packages/chains/avail-contracts/src/scaffold/template/identity.toml b/packages/chains/avail-contracts/src/scaffold/template/identity.toml new file mode 100644 index 000000000..04103a654 --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/identity.toml @@ -0,0 +1 @@ +avail_secret_uri = 'dream matrix amount add virus modify bunker crucial situate moon reflect blue danger real pride cattle retreat second erupt fitness eight pistol radar arctic' diff --git a/packages/chains/avail-contracts/src/scaffold/template/read-app.ts.rename b/packages/chains/avail-contracts/src/scaffold/template/read-app.ts.rename new file mode 100644 index 000000000..b3ab3b63c --- /dev/null +++ b/packages/chains/avail-contracts/src/scaffold/template/read-app.ts.rename @@ -0,0 +1,37 @@ +export type AvailApplicationInfo = { + appId: number; + txHash: { // The txHash of the apps creation transaction + value: string; + }; + ApplicationKey: string; + genesisHash: string; +}; + +let cachedAppInfo: AvailApplicationInfo | undefined; +export function readAvailApplication(): AvailApplicationInfo { + if (cachedAppInfo) return cachedAppInfo; + try { + // Get the directory of the current module file using Deno's URL API + const dir = new URL(".", import.meta.url); + // Construct the full path to avail_app.json + const appInfoPath = new URL("avail_app.json", dir); + const appInfoJson = Deno.readTextFileSync(appInfoPath); + const appInfo = JSON.parse(appInfoJson) as AvailApplicationInfo; + cachedAppInfo = appInfo; + return appInfo; + } catch (err) { + if (Deno) { + console.error(err); + throw new Error("avail_app.json not found in the current directory"); + } + // For frontend let's return a default app info + return { + appId: 0, + txHash: { + value: "", + }, + ApplicationKey: "", + genesisHash: "", + } + } +} diff --git a/packages/chains/bitcoin-contracts/src/scaffold/mod.ts b/packages/chains/bitcoin-contracts/src/scaffold/mod.ts new file mode 100644 index 000000000..8e387ea81 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/mod.ts @@ -0,0 +1 @@ +export * from './scaffold-bitcoin.ts'; diff --git a/packages/chains/bitcoin-contracts/src/scaffold/scaffold-bitcoin.ts b/packages/chains/bitcoin-contracts/src/scaffold/scaffold-bitcoin.ts new file mode 100644 index 000000000..25ed55847 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/scaffold-bitcoin.ts @@ -0,0 +1,88 @@ +import * as path from "@std/path"; +import { copyFiles } from "./scaffold-helpers.ts"; +import { currentDir } from "./scaffold-helpers.ts"; + +/* + * This module is responsible for scaffolding a new project. + */ +export async function scaffoldBitcoinProject( + targetFolder: string, + packageName: string, + version: string, +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/bitcoin-contracts`; + + const folders = [ + [""], + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles( + path.join(currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), { + replacements: { + "scope": packageName, + "EFFECTSTREAM-VERSION": version + }, + codeInsertions: { + }, + } + ); + } + + return { + name: fullPackageName, + path: targetFolder + } +}; + +export function bitcoinPrimitiveBlock(): string { + return ` + .addPrimitive( + (syncProtocols) => (syncProtocols as any).parallelBitcoin, + (network, deployments, syncProtocol) => ({ + name: "BitcoinAddress", + type: PrimitiveTypeBitcoinAddress, + startBlockHeight: 101, + watchAddress: "bcrt1qfv6m6l5s6cgda09yr5nd8rnufkaz59d3aquq03", + stateMachinePrefix: "bitcoin-transaction", + }), + ) + `; +} + +export function bitcoinGrammar(): { + customGrammar: string; + builtInGrammar: string; +} { + return { + builtInGrammar: `"bitcoin-transaction": builtinGrammars.bitcoinAddress,`, + customGrammar: '', + } +} + +export function bitcoinStateMachine(): string { + return ` + stm.addStateTransition("bitcoin-transaction", function* (data) { + console.log( + "🎉 [BITCOIN] Transaction receipt:", + JSON.stringify(data.parsedInput) + ); + + yield* World.resolve(insertData, { + chain: "bitcoin", + action: "transaction", + data: JSON.stringify(data.parsedInput), + block_height: data.blockHeight + }); + }); + `; +} + diff --git a/packages/chains/bitcoin-contracts/src/scaffold/scaffold-helpers.ts b/packages/chains/bitcoin-contracts/src/scaffold/scaffold-helpers.ts new file mode 100644 index 000000000..61ea9bed9 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/scaffold-helpers.ts @@ -0,0 +1,75 @@ +import * as path from "@std/path"; + +export function joinFile(...parts: string[]): string { + const filePath = path.join(...parts); + try { + const exists = Deno.statSync(filePath); + if (!exists.isFile) { + throw new Error(`File ${filePath} is not a file`); + } + return filePath; + } catch (error) { + throw new Error(`File ${filePath} does not exist`); + } +} + +// Copy files +export async function copyFiles( + sourceDir: string, + targetDir: string, + config: { + replacements?: Record, + codeBlocks?: Record, + codeInsertions?: Record, + replaceFileNames?: Record, + } = {}, +): Promise { + // console.log("Copying files from", sourceDir, "to", targetDir); + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(".rename", ""); + if (config.replaceFileNames?.[finalName]) { + finalName = config.replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(config.codeBlocks || {})) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(config.codeInsertions || {})) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(config.replacements || {})) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// Get current directory - this has to be JSR compatible. +export function currentDir(): string { + return path.dirname(path.fromFileUrl(import.meta.url)); +} \ No newline at end of file diff --git a/packages/chains/bitcoin-contracts/src/scaffold/template/deno.json.rename b/packages/chains/bitcoin-contracts/src/scaffold/template/deno.json.rename new file mode 100644 index 000000000..de2885dd5 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/template/deno.json.rename @@ -0,0 +1,20 @@ +{ + "name": "@[scope]/bitcoin-contracts", + "version": "0.3.0", + "exports": { + ".": "./generate-blocks.ts" + }, + "tasks": { + "chain:start": "deno run -A npm:@paimaexample/bitcoin-core", + "chain:wait": "wait-on tcp:18443", + "generate:blocks": "deno run -A ./generate-blocks.ts", + // "transfer-funds": "deno run -A transfer-funds.ts", + // "faucet:btc": "deno run -A faucet-btc.ts", + "wait-for-block": "deno run -A wait-for-block.ts" + }, + "imports": { + "bitcoinjs-lib": "npm:bitcoinjs-lib@6.1.5", + "ecpair": "npm:ecpair@2.1.0", + "tiny-secp256k1": "npm:tiny-secp256k1@2.2.3" + } +} \ No newline at end of file diff --git a/packages/chains/bitcoin-contracts/src/scaffold/template/generate-blocks.ts.rename b/packages/chains/bitcoin-contracts/src/scaffold/template/generate-blocks.ts.rename new file mode 100644 index 000000000..220191a53 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/template/generate-blocks.ts.rename @@ -0,0 +1,342 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import * as ecpair from 'ecpair'; +import * as ecc from 'tiny-secp256k1'; +import { createHash } from "node:crypto"; + +/** + * + * This script generates blocks continuously, so the blockchain is always progressing. + * As a side effect this script will seed some addresses with funds. + * + * Usage: + * deno run -A generate-blocks.ts --block-interval 5000 + * + * Arguments: + * - 1. Block interval in milliseconds + */ + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const ECPair = ecpair.ECPairFactory(ecc); +const SATS_PER_BTC = 100_000_000; + +const DEFAULT_BLOCK_INTERVAL = Deno.args.includes('--block-interval') ? parseInt(Deno.args[Deno.args.indexOf('--block-interval') + 1]) : 5000; +const NETWORK = bitcoin.networks.regtest; +console.log(`Using block interval: ${DEFAULT_BLOCK_INTERVAL}ms`); + +// Deterministic seed for the batcher wallet +const mySeedString = 'my-super-secret-regtest-demo-seed-e2e'; +const privateKeyBuffer = createHash('sha256').update(mySeedString).digest(); +const batcherKeyPair = ECPair.fromPrivateKey(privateKeyBuffer, { network: NETWORK }); +const { address: batcherAddress } = bitcoin.payments.p2wpkh({ + pubkey: batcherKeyPair.publicKey, + network: NETWORK, +}); + +const target = { + address: "bcrt1qfv6m6l5s6cgda09yr5nd8rnufkaz59d3aquq03", + privateKey: "cPNCP9RTgYu6aqw4cTFQgrrTKkz6oJPUnxuYeaDrWR5wAkDqwHjc", + publicKey: "03a7b23111f236dcd23f6ed0313d0ee1af18dc9cffffb9b09b3f8d8212515e5c11", +} + +console.log(`Target Address: ${target.address}`); + +// Generate a valid mock address for regtest +function generateMockAddress(): string { + const mockKeyPair = ECPair.makeRandom({ network: NETWORK }); + const mockPayment = bitcoin.payments.p2wpkh({ + pubkey: mockKeyPair.publicKey, + network: NETWORK, + }); + return mockPayment.address!; +} + +const MOCK_ADDRESS = generateMockAddress(); +console.log(`Generated mock address: ${MOCK_ADDRESS}`); + +// Helper function to make Bitcoin RPC calls +const bitcoinRpcCall = async (method: string, params: any[] = [], walletName?: string) => { + const url = walletName + ? `http://127.0.0.1:18443/wallet/${walletName}` + : 'http://127.0.0.1:18443'; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + btoa('dev:devpassword'), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: method, + params: params, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`RPC call failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(`RPC error: ${JSON.stringify(data.error)}`); + } + return data.result; +}; + +let running = true; + +// Handle process signals +if (typeof Deno !== 'undefined') { + Deno.addSignalListener('SIGINT', () => { + console.log('\nReceived SIGINT, stopping block generation...'); + running = false; + Deno.exit(130); + }); + + Deno.addSignalListener('SIGTERM', () => { + console.log('\nReceived SIGTERM, stopping block generation...'); + running = false; + Deno.exit(143); + }); +} + +async function generateTXHex(address: string, amountSats: number, inputUTXO: { txid: string, vout: number }) { + const keyPair = ECPair.fromWIF(target.privateKey, NETWORK); + const payment = bitcoin.payments.p2wpkh({ + pubkey: keyPair.publicKey, + network: NETWORK, + }); + + const oldTX = await bitcoinRpcCall('getrawtransaction', [inputUTXO.txid, 1]); + const utxo = oldTX.vout[inputUTXO.vout]; + if (!utxo) { + throw new Error(`UTXO not found: ${inputUTXO.txid}:${inputUTXO.vout}`); + } + const feeSats = 10_000; + const utxoValueSats = Math.round(utxo.value * SATS_PER_BTC); + if (utxoValueSats < amountSats + feeSats) { + throw new Error(`UTXO value too low: ${utxoValueSats} < ${amountSats + feeSats}`); + } + const psbt = new bitcoin.Psbt({ network: NETWORK }); + psbt.addInput({ + hash: inputUTXO.txid, + index: inputUTXO.vout, + sequence: 0xFFFFFFFF, + witnessUtxo: { + value: utxoValueSats, + script: payment.output!, + }, + }); + psbt.addOutput({ + address: address, + value: amountSats, + }); + psbt.addOutput({ + address: payment.address!, + value: utxoValueSats - amountSats - feeSats, + }); + psbt.signInput(0, keyPair); + psbt.finalizeAllInputs(); + const txHex = psbt.extractTransaction().toHex(); + return txHex; +} + +async function main() { + await delay(10000); + console.log('Block generator starting...'); + console.log('Assumes Bitcoin Core is already running at http://127.0.0.1:18443'); + + // Try to get or create a wallet and address + let address: string; + let walletName: string | undefined; + + // Create a mining wallet for generating blocks + walletName = 'miner'; + try { + await bitcoinRpcCall('createwallet', [walletName]); + console.log(`Created mining wallet: ${walletName}`); + } catch (e: any) { + // Wallet might already exist, try to load it + try { + await bitcoinRpcCall('loadwallet', [walletName]); + console.log(`Loaded existing mining wallet: ${walletName}`); + } catch (loadError) { + console.error('Failed to create or load mining wallet:', e); + console.log('Attempting to generate blocks without wallet...'); + walletName = undefined; + } + } + + // Get a new address from the wallet or default + try { + if (walletName) { + address = await bitcoinRpcCall('getnewaddress', [], walletName); + } else { + address = await bitcoinRpcCall('getnewaddress', []); + } + console.log(`Using mining wallet address: ${address}`); + } catch (error) { + console.error('Failed to get address. Make sure Bitcoin Core is running and accessible.'); + Deno.exit(1); + } + + // Import batcher address so we can track its funds + console.log(`Ensuring batcher address ${batcherAddress} is watched...`); + try { + // Try legacy importaddress first + await bitcoinRpcCall('importaddress', [batcherAddress, 'batcher', false], walletName); + } catch (e: any) { + // Fallback to descriptors + try { + const pubKeyHex = batcherKeyPair.publicKey.toString('hex'); + // Assuming P2WPKH + const descBase = `wpkh(${pubKeyHex})`; + const descInfo = await bitcoinRpcCall('getdescriptorinfo', [descBase], walletName); + + await bitcoinRpcCall('importdescriptors', [[{ + desc: descInfo.descriptor, + timestamp: 0, + active: true, + label: 'batcher' + }]], walletName); + console.log('Imported batcher descriptor'); + } catch (err) { + console.error('Failed to import batcher address:', err); + } + } + + console.log('\n=== Initialization Phase ==='); + + // Step 1: Generate 105 blocks to mining wallet to get funds + console.log(`Step 1: Generating 105 blocks to mining wallet...`); + + const initialBlocks = await bitcoinRpcCall('generatetoaddress', [105, address!], walletName); + console.log(`Generated 105 blocks. Latest block: ${initialBlocks[initialBlocks.length - 1]}`); + + // Check mining wallet balance + await delay(1000); + const miningBalance = await bitcoinRpcCall('getbalance', [], walletName); + console.log(`Mining wallet balance: ${miningBalance} BTC`); + + // Step 2: Send 100 BTC from mining wallet to batcher address + console.log(`Step 2: Sending 100 BTC from mining wallet to batcher address (${batcherAddress})...`); + const fundBatcherTxId = await bitcoinRpcCall('sendtoaddress', [batcherAddress!, 100], walletName); + console.log(`Funding transaction sent to ${batcherAddress}. TXID: ${fundBatcherTxId}`); + + // Step 3: Generate 1 block to confirm the funding transaction + console.log('Step 3: Generating 1 block to confirm funding...'); + const confirmBlocks = await bitcoinRpcCall('generatetoaddress', [1, address!], walletName); + console.log(`Confirmation block: ${confirmBlocks[0]}`); + + // Wait a bit for the wallet to index the new block + await delay(1000); + + // Check batcher balance + let batcherBalance = 0; + try { + const batcherUnspent = await bitcoinRpcCall('listunspent', [0, 9999999, [batcherAddress!]], walletName); + if (batcherUnspent.length > 0) { + batcherBalance = batcherUnspent.reduce((acc: number, utxo: any) => acc + utxo.amount, 0); + } else { + const scanResult = await bitcoinRpcCall('scantxoutset', ['start', [`addr(${batcherAddress})`]]); + batcherBalance = scanResult?.total_amount ?? 0; + } + } catch (error) { + console.warn('listunspent failed, falling back to scantxoutset:', error); + const scanResult = await bitcoinRpcCall('scantxoutset', ['start', [`addr(${batcherAddress})`]]); + batcherBalance = scanResult?.total_amount ?? 0; + } + console.log(`Batcher wallet balance (tracked): ${batcherBalance} BTC`); + + // Step 4: Send 10 BTC from mining wallet to target.address + console.log(`Step 4: Sending 10 BTC from mining wallet to ${target.address}...`); + + const sendTxId = await bitcoinRpcCall('sendtoaddress', [target.address, 10], walletName); + console.log(`Transaction sent. TXID: ${sendTxId}`); + + // Step 5: Generate 1 block to consolidate the transfer + console.log('Step 5: Generating 1 block to consolidate transfer...'); + const consolidateBlocks = await bitcoinRpcCall('generatetoaddress', [1, address!], walletName); + console.log(`Consolidation block: ${consolidateBlocks[0]}`); + + // Step 6: Find a UTXO from target.address and build transaction to MOCK_ADDRESS + console.log(`Step 6: Building transaction to send 3 BTC to ${MOCK_ADDRESS}...`); + + // Get the transaction details from step 4 to find the UTXO + console.log(`Fetching transaction details for ${sendTxId}...`); + const txDetails = await bitcoinRpcCall('getrawtransaction', [sendTxId, true]); + + if (!txDetails || !txDetails.vout) { + throw new Error(`Could not get transaction details for ${sendTxId}`); + } + + // Find the output that goes to target.address + let utxoVout = -1; + let utxoValue = 0; + console.log(`Transaction details: ${JSON.stringify(txDetails, null, 2)}`); + for (let i = 0; i < txDetails.vout.length; i++) { + const vout = txDetails.vout[i]; + // Check if this output goes to target.address + if (vout.scriptPubKey && vout.scriptPubKey.address) { + if (vout.scriptPubKey.address === target.address) { + utxoVout = i; + utxoValue = Math.round(vout.value * SATS_PER_BTC); // Convert BTC to satoshis + break; + } + } + } + + if (utxoVout === -1) { + throw new Error(`Could not find output to ${target.address} in transaction ${sendTxId}`); + } + + const requiredAmount = 3 * SATS_PER_BTC; + const feeSats = 10_000; + + if (utxoValue < requiredAmount + feeSats) { + throw new Error(`UTXO value too low: ${utxoValue} sats < ${requiredAmount + feeSats} sats`); + } + + console.log(`Using UTXO: ${sendTxId}:${utxoVout} (${utxoValue} sats = ${utxoValue / SATS_PER_BTC} BTC)`); + + // Build the transaction + const txHex = await generateTXHex(MOCK_ADDRESS, requiredAmount, { + txid: sendTxId, + vout: utxoVout, + }); + console.log(`Transaction built: ${txHex.substring(0, 64)}...`); + + // Step 5: Broadcast the transaction + console.log('Step 5: Broadcasting transaction...'); + const broadcastTxId = await bitcoinRpcCall('sendrawtransaction', [txHex]); + console.log(`Transaction broadcasted. TXID: ${broadcastTxId}`); + + console.log('=== Initialization Complete ===\n'); + + let blockCount = 102; + + console.log(`Starting block generation (every ${DEFAULT_BLOCK_INTERVAL}ms)...`); + console.log('Press Ctrl+C to stop\n'); + + while (running) { + try { + const blocks = await bitcoinRpcCall('generatetoaddress', [1, address!], walletName); + blockCount++; + const blockHash = blocks && blocks.length > 0 ? blocks[0] : 'unknown'; + console.log(`[${new Date().toISOString()}] Generated block #${blockCount}: ${blockHash}`); + } catch (error) { + console.error(`[${new Date().toISOString()}] Error generating block:`, error); + } + + await delay(DEFAULT_BLOCK_INTERVAL); + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + Deno.exit(1); +}); + diff --git a/packages/chains/bitcoin-contracts/src/scaffold/template/wait-for-block.ts.rename b/packages/chains/bitcoin-contracts/src/scaffold/template/wait-for-block.ts.rename new file mode 100644 index 000000000..111ae5a62 --- /dev/null +++ b/packages/chains/bitcoin-contracts/src/scaffold/template/wait-for-block.ts.rename @@ -0,0 +1,63 @@ +/** + * This script waits for a specific block to be mined. + * Usage: + * deno run -A wait-for-block.ts 100 + * + * Arguments: + * - 1. Block height to wait for + */ + +// Helper function to make Bitcoin RPC calls +const bitcoinRpcCall = async ( + method: string, + params: any[] = [], + walletName?: string +) => { + // console.log('Calling RPC method:', method); + const url = walletName + ? `http://127.0.0.1:18443/wallet/${walletName}` + : "http://127.0.0.1:18443"; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Basic " + btoa("dev:devpassword"), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: method, + params: params, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`RPC call failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(`RPC error: ${JSON.stringify(data.error)}`); + } + return data.result; +}; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function waitForBlock(targetBlock: number) { + while (true) { + const blockhash = await bitcoinRpcCall("getbestblockhash", []); + const block = await bitcoinRpcCall("getblock", [blockhash]); + if (block.height > targetBlock) { + return; + } + console.log(`Waiting for block: ${targetBlock}. Current block: ${block.height}`); + await delay(500); + } +} + +if (import.meta.main) { + await waitForBlock(100); +} \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/mod.ts b/packages/chains/cardano-contracts/src/scaffold/mod.ts new file mode 100644 index 000000000..b0287411c --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/mod.ts @@ -0,0 +1,2 @@ +export * from './scaffold-cardano.ts'; +export * from './scaffold-options.ts'; diff --git a/packages/chains/cardano-contracts/src/scaffold/scaffold-cardano.ts b/packages/chains/cardano-contracts/src/scaffold/scaffold-cardano.ts new file mode 100644 index 000000000..44459517c --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/scaffold-cardano.ts @@ -0,0 +1,155 @@ +import * as path from "jsr:@std/path"; + +/* + * This module is responsible for scaffolding a new project. + */ + +// Copy files +export async function copyFiles( + sourceDir: string, + targetDir: string, + config: { + replacements?: Record, + codeBlocks?: Record, + codeInsertions?: Record, + replaceFileNames?: Record, + } = {}, +): Promise { + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(/.rename$/, ""); + if (config.replaceFileNames?.[finalName]) { + finalName = config.replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(config.codeBlocks || {})) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(config.codeInsertions || {})) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(config.replacements || {})) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// Get current directory - this has to be JSR compatible. +function currentDir(): string { + return path.dirname(path.fromFileUrl(import.meta.url)); +} + +export async function scaffoldCardanoProject( + targetFolder: string, + packageName: string, + version: string, + contracts: { + safeCodeName: string, + safePackageName: string, + }[] +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/cardano-contracts`; + + const folders = [ + [""], + [""], + ["config"], + ["temp"], + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles( + path.join(currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), + { + replacements: { + "scope": packageName, + "EFFECTSTREAM-VERSION": version + }, + } + ); + } + + return { + name: fullPackageName, + path: targetFolder + } +}; + +export function cardanoPrimitiveBlock(): string { + return ``; +} + +export function cardanoGrammar(): { + customGrammar: string; + builtInGrammar: string; +} { + return { + builtInGrammar: '', + customGrammar: '', + } +} + +export function cardanoStateMachine(): string { + return ``; +} + +if (import.meta.main) { + function checkInputs(args: string[]): { targetFolder: string, packageName: string, version: string } { + const targetFolder = args[0]; + const packageName = args[1]; + const version = args[2]; + if (!targetFolder) { + console.error("Target folder is required"); + Deno.exit(1); + } + if (!packageName) { + console.error("Package name is required"); + Deno.exit(1); + } + if (!version) { + console.error("Version is required"); + Deno.exit(1); + } + return { + targetFolder: targetFolder.trim(), + packageName: packageName.trim(), + version: version.trim() + }; + } + checkInputs(Deno.args); + const { targetFolder, packageName, version } = checkInputs(Deno.args); + await scaffoldCardanoProject(targetFolder, packageName, version, []); +} diff --git a/packages/chains/cardano-contracts/src/scaffold/scaffold-options.ts b/packages/chains/cardano-contracts/src/scaffold/scaffold-options.ts new file mode 100644 index 000000000..113a97a12 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/scaffold-options.ts @@ -0,0 +1,4 @@ +export const cardanoContractOptions = [ + { label: 'Simple Token', value: 'simple-token' }, + { label: 'Empty Contract', value: 'empty-contract' }, +] diff --git a/packages/chains/cardano-contracts/src/scaffold/template/.gitignore.rename b/packages/chains/cardano-contracts/src/scaffold/template/.gitignore.rename new file mode 100644 index 000000000..186bb303a --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/.gitignore.rename @@ -0,0 +1,4 @@ +data/ +dolos.socket +yaci-cli.history +logs/ diff --git a/packages/chains/cardano-contracts/src/scaffold/template/config/application.properties b/packages/chains/cardano-contracts/src/scaffold/template/config/application.properties new file mode 100644 index 000000000..b56712acc --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/config/application.properties @@ -0,0 +1,34 @@ +spring.config.import=optional:file:./config/node.properties,optional:file:./config/download.properties + +#admin endpoint ports +server.port=10000 + +#Set the path to the directory where all yaci-cli related files are stored including cardano-node binary. +#Default is the user_home/.yaci-cli +#yaci.cli.home=/Users/satya/yacicli + +ogmios.enabled=false +kupo.enabled=false +yaci.store.enabled=false + +yaci.store.mode=native + +bp.create.enabled=true + +## Default ports +#ogmios.port=1337 +#kupo.port=1442 +#yaci.store.port=8080 +#socat.port=3333 +#prometheus.port=12798 + + +###################################################### +#To configure an external database for Yaci Store (Indexer), +# uncomment the following properties and provide the required values +#Only PostgreSQL is supported for now for external database +###################################################### + +#yaci.store.db.url=jdbc:postgresql://localhost:5433/yaci_indexer?currentSchema=dev +#yaci.store.db.username=user +#yaci.store.db.password= diff --git a/packages/chains/cardano-contracts/src/scaffold/template/config/download.properties b/packages/chains/cardano-contracts/src/scaffold/template/config/download.properties new file mode 100644 index 000000000..aa40db064 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/config/download.properties @@ -0,0 +1,12 @@ +#Please specify either the version or the full url for the following components +node.version=10.1.2 +ogmios.version=6.9.0 +kupo.version=2.9.0 +yaci.store.version=0.1.1-graalvm-preview1 +yaci.store.jar.version=0.1.0 + +#node.url= +#ogmios.url= +#kupo.url= +#yaci.store.url= +#yaci.store.jar.url= diff --git a/packages/chains/cardano-contracts/src/scaffold/template/config/node.properties b/packages/chains/cardano-contracts/src/scaffold/template/config/node.properties new file mode 100644 index 000000000..4fabd1a64 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/config/node.properties @@ -0,0 +1,99 @@ +#protocolMagic=42 +#maxKESEvolutions=60 +#securityParam=80 +#slotsPerKESPeriod=129600 +#updateQuorum=1 +#peerSharing=true + +## Shelley Genesis +#maxLovelaceSupply=45000000000000000 +#poolPledgeInfluence=0 +#decentralisationParam=0 +#eMax=18 +#keyDeposit=2000000 +#maxBlockBodySize=65536 +#maxBlockHeaderSize=1100 +#maxTxSize=16384 +#minFeeA=44 +#minFeeB=155381 +#minPoolCost=340000000 +#minUTxOValue=1000000 +#nOpt=100 +#poolDeposit=500000000 + +#protocolMajorVer=8 +#protocolMinorVer=0 +#monetaryExpansionRate=0.003f +#treasuryGrowthRate=0.20f + +##Default addresses +#initialAddresses[0].address=addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y +#initialAddresses[0].balance=450000000 +#initialAddresses[0].staked=true +# +#initialAddresses[1].address=addr_test1qqwpl7h3g84mhr36wpetk904p7fchx2vst0z696lxk8ujsjyruqwmlsm344gfux3nsj6njyzj3ppvrqtt36cp9xyydzqzumz82 +#initialAddresses[1].balance=250000000 +#initialAddresses[1].staked=false + +##Alonzo +#collateralPercentage=150 +#prMem=5.77e-2 +#prSteps=7.21e-5 +#lovelacePerUTxOWord=34482 +#maxBlockExUnitsMem=62000000 +#maxBlockExUnitsSteps=20000000000 +#maxCollateralInputs=3 +#maxTxExUnitsMem=14000000 +#maxTxExUnitsSteps=10000000000 +#maxValueSize=5000 + +##Conway +#pvtcommitteeNormal=0.51f +#pvtCommitteeNoConfidence=0.51f +#pvtHardForkInitiation=0.51f +#pvtMotionNoConfidence=0.51f +#pvtPPSecurityGroup=0.51f + +#dvtMotionNoConfidence=0.51f +#dvtCommitteeNormal=0.51f +#dvtCommitteeNoConfidence=0.51f +#dvtUpdateToConstitution=0.51f +#dvtHardForkInitiation=0.51f +#dvtPPNetworkGroup=0.51f +#dvtPPEconomicGroup=0.51f +#dvtPPTechnicalGroup=0.51f +#dvtPPGovGroup=0.51f +#dvtTreasuryWithdrawal=0.51f + +#committeeMinSize=0 +#committeeMaxTermLength=200 +#govActionLifetime=10 +#govActionDeposit=1000000000 +#dRepDeposit=2000000 +#dRepActivity=20 + +#constitutionScript=7713eb6a46b67bfa1ca082f2b410b0a4e502237d03f7a0b7cbf1b025 +#constitutionUrl=https://devkit.yaci.xyz/constitution.json +#constitutionDataHash=f89cc2469ce31c3dfda2f3e0b56c5c8b4ee4f0e5f66c30a3f12a95298b01179e + +## CC Members +#ccMembers[0].hash=scriptHash-8fc13431159fdda66347a38c55105d50d77d67abc1c368b876d52ad1 +#ccMembers[0].term=340 + +######################################################################################################## +# Workaround for : https://github.com/bloxbean/yaci-devkit/issues/65 +# +# The following parameters are enabled for a V2 cost model-related issue where there are 10 extra elements if the devnet +# is started with the Conway era at epoch 0. The following parameters are enabled to configure the Conway era hard fork (HF) at epoch 1. +# The network will start in the Babbage era and then hard fork (HF) to the Conway era at epoch 1. + +# The shiftStartTimeBehind=true flag is enabled to shift the start time of the network to a time behind the current time by adjusting security parameter +# which changes the stability window. This is to speed up the process of reaching the Conway era. +# +# This should only be done in a development environment because if the stability window is larger than the epoch length, the reward/treasury calculations will be incorrect or ignored. +# Therefore, for a real multi-node network, you should start the network at the current time and allow it to reach the Conway era at epoch 1. +# So, the shiftStartTimeBehind flag should be "false" for non-development / multi-node networks. +# +######################################################################################################### +conwayHardForkAtEpoch=1 +shiftStartTimeBehind=true diff --git a/packages/chains/cardano-contracts/src/scaffold/template/deno.json.rename b/packages/chains/cardano-contracts/src/scaffold/template/deno.json.rename new file mode 100644 index 000000000..fd538f308 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/deno.json.rename @@ -0,0 +1,19 @@ +{ + "name": "@[scope]/cardano-contracts", + "version": "0.3.0", + "license": "MIT", + "exports": {}, + "tasks": { + "devkit:start": "deno run -A --node-modules-dir npm:@bloxbean/yaci-devkit up", + "devkit:wait": "wait-on tcp:3001", + "dolos:fill-template": "deno run -A ./fill-template.ts", + // rm -rf is required because of this issue: https://github.com/txpipe/dolos/issues/398 + "dolos:start": "rm -rf ./data && rm -rf ./dolos.socket && deno task dolos:fill-template && deno run -A --node-modules-dir npm:@txpipe/dolos bootstrap relay && deno run -A --node-modules-dir npm:@txpipe/dolos daemon", + "dolos:wait": "wait-on tcp:50051" // utxorpc port + }, + "imports": { + "@bloxbean/yaci-devkit": "npm:@bloxbean/yaci-devkit@0.10.6", + "@txpipe/dolos": "npm:@txpipe/dolos@0.19.1", + "toml": "jsr:@std/toml" + } +} \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/template/dolos.template.toml b/packages/chains/cardano-contracts/src/scaffold/template/dolos.template.toml new file mode 100644 index 000000000..65544108c --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/dolos.template.toml @@ -0,0 +1,40 @@ +# peer_address here should be dynamically generated (see: https://github.com/bloxbean/yaci-devkit/issues/87) +# this comes from yaci-devkit +[upstream] +peer_address = "localhost:3001" +network_magic = 42 # comes from the shelley genesis block +is_testnet = true + +[storage] +path = "data" +max_wal_history = 10000 + +[genesis] +# note: filled programmatically + +[sync] +pull_batch_size = 100 + +[submit] +prune_height = 200 + +[serve.grpc] +listen_address = "[::]:50051" +magic = 42 + +[serve.minibf] +listen_address = "[::]:3051" + +[serve.ouroboros] +listen_path = "dolos.socket" +magic = 42 + +[relay] +listen_address = "[::]:30031" +magic = 42 + +[logging] +max_level = "INFO" +include_tokio = false +include_pallas = false +include_grpc = true diff --git a/packages/chains/cardano-contracts/src/scaffold/template/dolos.toml b/packages/chains/cardano-contracts/src/scaffold/template/dolos.toml new file mode 100644 index 000000000..a67049093 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/dolos.toml @@ -0,0 +1,42 @@ + +[upstream] +peer_address = "localhost:3001" +network_magic = 42 +is_testnet = true + +[storage] +path = "data" +max_wal_history = 10000 + +[genesis] +byron_path = "./temp/byron-genesis.json" +shelley_path = "./temp/shelley-genesis.json" +alonzo_path = "./temp/alonzo-genesis2.json" +conway_path = "./temp/conway-genesis.json" + +[sync] +pull_batch_size = 100 + +[submit] +prune_height = 200 + +[serve.grpc] +listen_address = "[::]:50051" +magic = 42 + +[serve.minibf] +listen_address = "[::]:3051" + +[serve.ouroboros] +listen_path = "dolos.socket" +magic = 42 + +[relay] +listen_address = "[::]:30031" +magic = 42 + +[logging] +max_level = "INFO" +include_tokio = false +include_pallas = false +include_grpc = true diff --git a/packages/chains/cardano-contracts/src/scaffold/template/fill-template.ts.rename b/packages/chains/cardano-contracts/src/scaffold/template/fill-template.ts.rename new file mode 100644 index 000000000..2369f2c44 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/fill-template.ts.rename @@ -0,0 +1,58 @@ +import { parse as parseToml, stringify as stringifyToml } from "toml"; +import fs from "node:fs/promises"; + +const TEMP_DIR = "./temp"; +const TEMPLATE_FILE = "./dolos.template.toml"; +const FINAL_TOML = "./dolos.toml"; +const BASE_URL = (hostname: string, port: number) => + `http://${hostname}:${port}/local-cluster/api/admin/devnet`; +const GENESIS_ENDPOINTS = { + byron: BASE_URL("localhost", 10000) + "/genesis/byron", + shelley: BASE_URL("localhost", 10000) + "/genesis/shelley", + alonzo: BASE_URL("localhost", 10000) + "/genesis/alonzo", + conway: BASE_URL("localhost", 10000) + "/genesis/conway", +}; + +async function fetchAndSaveGenesis( + type: keyof typeof GENESIS_ENDPOINTS, +): Promise { + const response = await fetch(GENESIS_ENDPOINTS[type]); + if (!response.ok) { + throw new Error(`Failed to fetch ${type} genesis: ${response.statusText}`); + } + + const json = await response.json(); + const filePath = `${TEMP_DIR}/${type}-genesis.json`; + + await fs.mkdir(TEMP_DIR, { recursive: true }); + await Deno.writeTextFile(filePath, JSON.stringify(json, null, 2)); + + return filePath; +} + +async function updateDolosConfig() { + // Fetch and save all genesis files + const paths = await Promise.all( + Object.keys(GENESIS_ENDPOINTS).map((type) => + fetchAndSaveGenesis(type as keyof typeof GENESIS_ENDPOINTS) + ), + ); + + // Read the template file + const templateContent = await Deno.readTextFile(TEMPLATE_FILE); + const config = parseToml(templateContent); + + // Update genesis paths + config.genesis = { + byron_path: paths[0], + shelley_path: paths[1], + alonzo_path: "./temp/alonzo-genesis2.json", // https://github.com/txpipe/pallas/issues/296#issuecomment-2547962797 + conway_path: paths[3], + }; + + // Write updated config back to file + await Deno.writeTextFile(FINAL_TOML, stringifyToml(config)); +} + +// Execute the update +await updateDolosConfig(); diff --git a/packages/chains/cardano-contracts/src/scaffold/template/package.json.rename b/packages/chains/cardano-contracts/src/scaffold/template/package.json.rename new file mode 100644 index 000000000..c0b0ee59f --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/package.json.rename @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "wait-on": "8.0.3" + } +} diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/.gitignore.rename b/packages/chains/cardano-contracts/src/scaffold/template/temp/.gitignore.rename new file mode 100644 index 000000000..546a03934 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/.gitignore.rename @@ -0,0 +1,2 @@ +*.json +!./alonzo-genesis2.json \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis.json b/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis.json new file mode 100644 index 000000000..214fd8c84 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis.json @@ -0,0 +1,371 @@ +{ + "collateralPercentage": 150, + "costModels": { + "PlutusV1": [ + 100788, + 420, + 1, + 1, + 1000, + 173, + 0, + 1, + 1000, + 59957, + 4, + 1, + 11183, + 32, + 201305, + 8356, + 4, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 100, + 100, + 16000, + 100, + 94375, + 32, + 132994, + 32, + 61462, + 4, + 72010, + 178, + 0, + 1, + 22151, + 32, + 91189, + 769, + 4, + 2, + 85848, + 228465, + 122, + 0, + 1, + 1, + 1000, + 42921, + 4, + 2, + 24548, + 29498, + 38, + 1, + 898148, + 27279, + 1, + 51775, + 558, + 1, + 39184, + 1000, + 60594, + 1, + 141895, + 32, + 83150, + 32, + 15299, + 32, + 76049, + 1, + 13169, + 4, + 22100, + 10, + 28999, + 74, + 1, + 28999, + 74, + 1, + 43285, + 552, + 1, + 44749, + 541, + 1, + 33852, + 32, + 68246, + 32, + 72362, + 32, + 7243, + 32, + 7391, + 32, + 11546, + 32, + 85848, + 228465, + 122, + 0, + 1, + 1, + 90434, + 519, + 0, + 1, + 74433, + 32, + 85848, + 228465, + 122, + 0, + 1, + 1, + 85848, + 228465, + 122, + 0, + 1, + 1, + 270652, + 22588, + 4, + 1457325, + 64566, + 4, + 20467, + 1, + 4, + 0, + 141992, + 32, + 100788, + 420, + 1, + 1, + 81663, + 32, + 59498, + 32, + 20142, + 32, + 24588, + 32, + 20744, + 32, + 25933, + 32, + 24623, + 32, + 53384111, + 14333, + 10 + ], + "PlutusV2": [ + 100788, + 420, + 1, + 1, + 1000, + 173, + 0, + 1, + 1000, + 59957, + 4, + 1, + 11183, + 32, + 201305, + 8356, + 4, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 100, + 100, + 16000, + 100, + 94375, + 32, + 132994, + 32, + 61462, + 4, + 72010, + 178, + 0, + 1, + 22151, + 32, + 91189, + 769, + 4, + 2, + 85848, + 228465, + 122, + 0, + 1, + 1, + 1000, + 42921, + 4, + 2, + 24548, + 29498, + 38, + 1, + 898148, + 27279, + 1, + 51775, + 558, + 1, + 39184, + 1000, + 60594, + 1, + 141895, + 32, + 83150, + 32, + 15299, + 32, + 76049, + 1, + 13169, + 4, + 22100, + 10, + 28999, + 74, + 1, + 28999, + 74, + 1, + 43285, + 552, + 1, + 44749, + 541, + 1, + 33852, + 32, + 68246, + 32, + 72362, + 32, + 7243, + 32, + 7391, + 32, + 11546, + 32, + 85848, + 228465, + 122, + 0, + 1, + 1, + 90434, + 519, + 0, + 1, + 74433, + 32, + 85848, + 228465, + 122, + 0, + 1, + 1, + 85848, + 228465, + 122, + 0, + 1, + 1, + 955506, + 213312, + 0, + 2, + 270652, + 22588, + 4, + 1457325, + 64566, + 4, + 20467, + 1, + 4, + 0, + 141992, + 32, + 100788, + 420, + 1, + 1, + 81663, + 32, + 59498, + 32, + 20142, + 32, + 24588, + 32, + 20744, + 32, + 25933, + 32, + 24623, + 32, + 43053543, + 10, + 53384111, + 14333, + 10, + 43574283, + 26308, + 10 + ] + }, + "executionPrices": { + "prSteps": { + "numerator": 721, + "denominator": 10000000 + }, + "prMem": { + "numerator": 577, + "denominator": 10000 + } + }, + "lovelacePerUTxOWord": 34482, + "maxBlockExUnits": { + "exUnitsMem": 62000000, + "exUnitsSteps": 20000000000 + }, + "maxCollateralInputs": 3, + "maxTxExUnits": { + "exUnitsMem": 14000000, + "exUnitsSteps": 10000000000 + }, + "maxValueSize": 5000 +} \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis2.json b/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis2.json new file mode 100644 index 000000000..e435020d9 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/alonzo-genesis2.json @@ -0,0 +1,194 @@ +{ + "collateralPercentage": 150, +"costModels": { + "PlutusV1": { + "sha2_256-memory-arguments": 4, + "equalsString-cpu-arguments-constant": 1000, + "cekDelayCost-exBudgetMemory": 100, + "lessThanEqualsByteString-cpu-arguments-intercept": 103599, + "divideInteger-memory-arguments-minimum": 1, + "appendByteString-cpu-arguments-slope": 621, + "blake2b-cpu-arguments-slope": 29175, + "iData-cpu-arguments": 150000, + "encodeUtf8-cpu-arguments-slope": 1000, + "unBData-cpu-arguments": 150000, + "multiplyInteger-cpu-arguments-intercept": 61516, + "cekConstCost-exBudgetMemory": 100, + "nullList-cpu-arguments": 150000, + "equalsString-cpu-arguments-intercept": 150000, + "trace-cpu-arguments": 150000, + "mkNilData-memory-arguments": 32, + "lengthOfByteString-cpu-arguments": 150000, + "cekBuiltinCost-exBudgetCPU": 29773, + "bData-cpu-arguments": 150000, + "subtractInteger-cpu-arguments-slope": 0, + "unIData-cpu-arguments": 150000, + "consByteString-memory-arguments-intercept": 0, + "divideInteger-memory-arguments-slope": 1, + "divideInteger-cpu-arguments-model-arguments-slope": 118, + "listData-cpu-arguments": 150000, + "headList-cpu-arguments": 150000, + "chooseData-memory-arguments": 32, + "equalsInteger-cpu-arguments-intercept": 136542, + "sha3_256-cpu-arguments-slope": 82363, + "sliceByteString-cpu-arguments-slope": 5000, + "unMapData-cpu-arguments": 150000, + "lessThanInteger-cpu-arguments-intercept": 179690, + "mkCons-cpu-arguments": 150000, + "appendString-memory-arguments-intercept": 0, + "modInteger-cpu-arguments-model-arguments-slope": 118, + "ifThenElse-cpu-arguments": 1, + "mkNilPairData-cpu-arguments": 150000, + "lessThanEqualsInteger-cpu-arguments-intercept": 145276, + "addInteger-memory-arguments-slope": 1, + "chooseList-memory-arguments": 32, + "constrData-memory-arguments": 32, + "decodeUtf8-cpu-arguments-intercept": 150000, + "equalsData-memory-arguments": 1, + "subtractInteger-memory-arguments-slope": 1, + "appendByteString-memory-arguments-intercept": 0, + "lengthOfByteString-memory-arguments": 4, + "headList-memory-arguments": 32, + "listData-memory-arguments": 32, + "consByteString-cpu-arguments-intercept": 150000, + "unIData-memory-arguments": 32, + "remainderInteger-memory-arguments-minimum": 1, + "bData-memory-arguments": 32, + "lessThanByteString-cpu-arguments-slope": 248, + "encodeUtf8-memory-arguments-intercept": 0, + "cekStartupCost-exBudgetCPU": 100, + "multiplyInteger-memory-arguments-intercept": 0, + "unListData-memory-arguments": 32, + "remainderInteger-cpu-arguments-model-arguments-slope": 118, + "cekVarCost-exBudgetCPU": 29773, + "remainderInteger-memory-arguments-slope": 1, + "cekForceCost-exBudgetCPU": 29773, + "sha2_256-cpu-arguments-slope": 29175, + "equalsInteger-memory-arguments": 1, + "indexByteString-memory-arguments": 1, + "addInteger-memory-arguments-intercept": 1, + "chooseUnit-cpu-arguments": 150000, + "sndPair-cpu-arguments": 150000, + "cekLamCost-exBudgetCPU": 29773, + "fstPair-cpu-arguments": 150000, + "quotientInteger-memory-arguments-minimum": 1, + "decodeUtf8-cpu-arguments-slope": 1000, + "lessThanInteger-memory-arguments": 1, + "lessThanEqualsInteger-cpu-arguments-slope": 1366, + "fstPair-memory-arguments": 32, + "modInteger-memory-arguments-intercept": 0, + "unConstrData-cpu-arguments": 150000, + "lessThanEqualsInteger-memory-arguments": 1, + "chooseUnit-memory-arguments": 32, + "sndPair-memory-arguments": 32, + "addInteger-cpu-arguments-intercept": 197209, + "decodeUtf8-memory-arguments-slope": 8, + "equalsData-cpu-arguments-intercept": 150000, + "mapData-cpu-arguments": 150000, + "mkPairData-cpu-arguments": 150000, + "quotientInteger-cpu-arguments-constant": 148000, + "consByteString-memory-arguments-slope": 1, + "cekVarCost-exBudgetMemory": 100, + "indexByteString-cpu-arguments": 150000, + "unListData-cpu-arguments": 150000, + "equalsInteger-cpu-arguments-slope": 1326, + "cekStartupCost-exBudgetMemory": 100, + "subtractInteger-cpu-arguments-intercept": 197209, + "divideInteger-cpu-arguments-model-arguments-intercept": 425507, + "divideInteger-memory-arguments-intercept": 0, + "cekForceCost-exBudgetMemory": 100, + "blake2b-cpu-arguments-intercept": 2477736, + "remainderInteger-cpu-arguments-constant": 148000, + "tailList-cpu-arguments": 150000, + "encodeUtf8-cpu-arguments-intercept": 150000, + "equalsString-cpu-arguments-slope": 1000, + "lessThanByteString-memory-arguments": 1, + "multiplyInteger-cpu-arguments-slope": 11218, + "appendByteString-cpu-arguments-intercept": 396231, + "lessThanEqualsByteString-cpu-arguments-slope": 248, + "modInteger-memory-arguments-slope": 1, + "addInteger-cpu-arguments-slope": 0, + "equalsData-cpu-arguments-slope": 10000, + "decodeUtf8-memory-arguments-intercept": 0, + "chooseList-cpu-arguments": 150000, + "constrData-cpu-arguments": 150000, + "equalsByteString-memory-arguments": 1, + "cekApplyCost-exBudgetCPU": 29773, + "quotientInteger-memory-arguments-slope": 1, + "verifySignature-cpu-arguments-intercept": 3345831, + "unMapData-memory-arguments": 32, + "mkCons-memory-arguments": 32, + "sliceByteString-memory-arguments-slope": 1, + "sha3_256-memory-arguments": 4, + "ifThenElse-memory-arguments": 1, + "mkNilPairData-memory-arguments": 32, + "equalsByteString-cpu-arguments-slope": 247, + "appendString-cpu-arguments-intercept": 150000, + "quotientInteger-cpu-arguments-model-arguments-slope": 118, + "cekApplyCost-exBudgetMemory": 100, + "equalsString-memory-arguments": 1, + "multiplyInteger-memory-arguments-slope": 1, + "cekBuiltinCost-exBudgetMemory": 100, + "remainderInteger-memory-arguments-intercept": 0, + "sha2_256-cpu-arguments-intercept": 2477736, + "remainderInteger-cpu-arguments-model-arguments-intercept": 425507, + "lessThanEqualsByteString-memory-arguments": 1, + "tailList-memory-arguments": 32, + "mkNilData-cpu-arguments": 150000, + "chooseData-cpu-arguments": 150000, + "unBData-memory-arguments": 32, + "blake2b-memory-arguments": 4, + "iData-memory-arguments": 32, + "nullList-memory-arguments": 32, + "cekDelayCost-exBudgetCPU": 29773, + "subtractInteger-memory-arguments-intercept": 1, + "lessThanByteString-cpu-arguments-intercept": 103599, + "consByteString-cpu-arguments-slope": 1000, + "appendByteString-memory-arguments-slope": 1, + "trace-memory-arguments": 32, + "divideInteger-cpu-arguments-constant": 148000, + "cekConstCost-exBudgetCPU": 29773, + "encodeUtf8-memory-arguments-slope": 8, + "quotientInteger-cpu-arguments-model-arguments-intercept": 425507, + "mapData-memory-arguments": 32, + "appendString-cpu-arguments-slope": 1000, + "modInteger-cpu-arguments-constant": 148000, + "verifySignature-cpu-arguments-slope": 1, + "unConstrData-memory-arguments": 32, + "quotientInteger-memory-arguments-intercept": 0, + "equalsByteString-cpu-arguments-constant": 150000, + "sliceByteString-memory-arguments-intercept": 0, + "mkPairData-memory-arguments": 32, + "equalsByteString-cpu-arguments-intercept": 112536, + "appendString-memory-arguments-slope": 1, + "lessThanInteger-cpu-arguments-slope": 497, + "modInteger-cpu-arguments-model-arguments-intercept": 425507, + "modInteger-memory-arguments-minimum": 1, + "sha3_256-cpu-arguments-intercept": 0, + "verifySignature-memory-arguments": 1, + "cekLamCost-exBudgetMemory": 100, + "sliceByteString-cpu-arguments-intercept": 150000 + } +}, + "executionPrices": { + "prMem": { + "numerator": 577, + "denominator": 10000 + }, + "prSteps": { + "numerator": 721, + "denominator": 10000000 + } + }, + "lovelacePerUTxOWord": 34482, + "maxBlockExUnits": { + "exUnitsMem": 62000000, + "exUnitsSteps": 20000000000 + }, + "maxCollateralInputs": 3, + "maxTxExUnits": { + "exUnitsMem": 14000000, + "exUnitsSteps": 10000000000 + }, + "maxValueSize": 5000 +} diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/byron-genesis.json b/packages/chains/cardano-contracts/src/scaffold/template/temp/byron-genesis.json new file mode 100644 index 000000000..7ac4a7b07 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/byron-genesis.json @@ -0,0 +1,45 @@ +{ + "bootStakeholders": { + "4c23c4a699c4245f41c79d444e0a3322edbf66daa7efe001c6c8657c": 1 + }, + "heavyDelegation": { + "4c23c4a699c4245f41c79d444e0a3322edbf66daa7efe001c6c8657c": { + "omega": 0, + "issuerPk": "+AkxDu8deptOlFXf1QMC0ys/w0y7mjqHRCqybUequeotqUVCz1h1HSOCNK5eBPE5svg2tHyQJKQzToAfCiSDOg==", + "delegatePk": "OBjvlmcUFmFHcRV27X2eRjBjAexq/Q0KiYDwkeEYbtJwT0xPnjn1+NE8oI4ePOA/M4mtHbtuYf40wLdvJVRCnw==", + "cert": "44327b2748c561d6bfed3d3f62dde3745d89b768a24dfa5977908433afb53611bdd841f70b33554ad57aef804448f9a09132c55ab08add5aa3b7dab150c2ae0b" + } + }, + "startTime": 1762962737, + "nonAvvmBalances": { + "2657WMsDfac6EtPTiPEptLHDYUVYD5DtRpTmVWb6X95beFrKXqPULmyvCwmCxZEGN": "3340000000" + }, + "blockVersionData": { + "scriptVersion": 0, + "slotDuration": "1000", + "maxBlockSize": "2000000", + "maxHeaderSize": "2000000", + "maxTxSize": "4096", + "maxProposalSize": "700", + "mpcThd": "20000000000000", + "heavyDelThd": "300000000000", + "updateVoteThd": "1000000000000", + "updateProposalThd": "100000000000000", + "updateImplicit": "10000", + "softforkRule": { + "initThd": "900000000000000", + "minThd": "600000000000000", + "thdDecrement": "50000000000000" + }, + "txFeePolicy": { + "summand": "155381000000000", + "multiplier": "43946000000" + }, + "unlockStakeEpoch": "18446744073709551615" + }, + "protocolConsts": { + "k": 10, + "protocolMagic": 42 + }, + "avvmDistr": {} +} \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/conway-genesis.json b/packages/chains/cardano-contracts/src/scaffold/template/temp/conway-genesis.json new file mode 100644 index 000000000..167518c6e --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/conway-genesis.json @@ -0,0 +1,341 @@ +{ + "poolVotingThresholds": { + "committeeNormal": 0.51, + "committeeNoConfidence": 0.51, + "hardForkInitiation": 0.51, + "motionNoConfidence": 0.51, + "ppSecurityGroup": 0.51 + }, + "dRepVotingThresholds": { + "motionNoConfidence": 0.67, + "committeeNormal": 0.67, + "committeeNoConfidence": 0.6, + "updateToConstitution": 0.75, + "hardForkInitiation": 0.6, + "ppNetworkGroup": 0.67, + "ppEconomicGroup": 0.67, + "ppTechnicalGroup": 0.67, + "ppGovGroup": 0.75, + "treasuryWithdrawal": 0.67 + }, + "committeeMinSize": 0, + "committeeMaxTermLength": 146, + "govActionLifetime": 6, + "govActionDeposit": 1000000000, + "dRepDeposit": 500000000, + "dRepActivity": 20, + "minFeeRefScriptCostPerByte": 15, + "plutusV3CostModel": [ + 100788, + 420, + 1, + 1, + 1000, + 173, + 0, + 1, + 1000, + 59957, + 4, + 1, + 11183, + 32, + 201305, + 8356, + 4, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 100, + 100, + 16000, + 100, + 94375, + 32, + 132994, + 32, + 61462, + 4, + 72010, + 178, + 0, + 1, + 22151, + 32, + 91189, + 769, + 4, + 2, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 1, + 1000, + 42921, + 4, + 2, + 24548, + 29498, + 38, + 1, + 898148, + 27279, + 1, + 51775, + 558, + 1, + 39184, + 1000, + 60594, + 1, + 141895, + 32, + 83150, + 32, + 15299, + 32, + 76049, + 1, + 13169, + 4, + 22100, + 10, + 28999, + 74, + 1, + 28999, + 74, + 1, + 43285, + 552, + 1, + 44749, + 541, + 1, + 33852, + 32, + 68246, + 32, + 72362, + 32, + 7243, + 32, + 7391, + 32, + 11546, + 32, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 90434, + 519, + 0, + 1, + 74433, + 32, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 1, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 955506, + 213312, + 0, + 2, + 270652, + 22588, + 4, + 1457325, + 64566, + 4, + 20467, + 1, + 4, + 0, + 141992, + 32, + 100788, + 420, + 1, + 1, + 81663, + 32, + 59498, + 32, + 20142, + 32, + 24588, + 32, + 20744, + 32, + 25933, + 32, + 24623, + 32, + 43053543, + 10, + 53384111, + 14333, + 10, + 43574283, + 26308, + 10, + 16000, + 100, + 16000, + 100, + 962335, + 18, + 2780678, + 6, + 442008, + 1, + 52538055, + 3756, + 18, + 267929, + 18, + 76433006, + 8868, + 18, + 52948122, + 18, + 1995836, + 36, + 3227919, + 12, + 901022, + 1, + 166917843, + 4307, + 36, + 284546, + 36, + 158221314, + 26549, + 36, + 74698472, + 36, + 333849714, + 1, + 254006273, + 72, + 2174038, + 72, + 2261318, + 64571, + 4, + 207616, + 8310, + 4, + 1293828, + 28716, + 63, + 0, + 1, + 1006041, + 43623, + 251, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 107878, + 680, + 0, + 1, + 95336, + 1, + 281145, + 18848, + 0, + 1, + 180194, + 159, + 1, + 1, + 158519, + 8942, + 0, + 1, + 159378, + 8813, + 0, + 1, + 107490, + 3298, + 1, + 106057, + 655, + 1, + 1964219, + 24520, + 3 + ], + "constitution": { + "anchor": { + "url": "https://devkit.yaci.xyz/constitution.json", + "dataHash": "f89cc2469ce31c3dfda2f3e0b56c5c8b4ee4f0e5f66c30a3f12a95298b01179e" + }, + "script": "186e32faa80a26810392fda6d559c7ed4721a65ce1c9d4ef3e1c87b4" + }, + "committee": { + "members": {}, + "threshold": { + "numerator": 2, + "denominator": 3 + } + } +} \ No newline at end of file diff --git a/packages/chains/cardano-contracts/src/scaffold/template/temp/shelley-genesis.json b/packages/chains/cardano-contracts/src/scaffold/template/temp/shelley-genesis.json new file mode 100644 index 000000000..a0c9ec3b9 --- /dev/null +++ b/packages/chains/cardano-contracts/src/scaffold/template/temp/shelley-genesis.json @@ -0,0 +1,92 @@ +{ + "activeSlotsCoeff": 1, + "epochLength": 600, + "genDelegs": { + "337bc5ef0f1abf205624555c13a37258c42b46b1259a6b1a6d82574e": { + "delegate": "41fd6bb31f34469320aa47cf6ccc3918e58a06d60ea1f2361efe2458", + "vrf": "7053e3ecd2b19db13e5338aa75fb518fc08b6c218f56ad65760d3eb074be95d4" + } + }, + "initialFunds": { + "00c8c47610a36034aac6fc58848bdae5c278d994ff502c05455e3b3ee8f8ed3a0eea0ef835ffa7bbfcde55f7fe9d2cc5d55ea62cecb42bab3c": 10000000000, + "004048ff89ca4f88e66598e620aa0c7128c2145d9a181ae9a4a81ca8e3e849af38840c5562dd382be37c9e76545c8191f9d8f6df1d20cfcee0": 10000000000, + "00ca6e1b1f320d543a24adeabc0aa4627635c7349b639f86f74bdfdd78d31b28c9619a58b3792a7394ab85deb36889c4d7b0632c8167b855d2": 10000000000, + "0007d781fe8e33883e371f9550c2f1087321fc32e06e80b65e349ccb027702d6880e86e77a0520efa37ede45002a1de43b68692e175b742e67": 10000000000, + "00627b2598dd71129167825160c564067d1d245e79cc237094815c5cb2b125e30ec2f4ce4059a069e08c3cd82cdfc9451bfb22487f8a25ceef": 10000000000, + "00c6cf7bd50f37f7e4cc161fc00f07e9b2226ba5552ccaf30d315fa0135bbc8cbd9ab5379f368fc8d3500c37a9d14074cc6ddad89e3686f0e0": 10000000000, + "005164ab186715c86378020956d892cf72f67636b78967d67cfe7360479130dc89cf7a9bc89109f939956b66f93293ade4c3920b72fd40beea": 10000000000, + "003dd38742e9848c6f12c13ddb1f9464fc0ce0bb92102768087975317e5a9f869fcd913562c9b0e0f01f77e5359ea780d37f9355f9702eff8b": 10000000000, + "0088e7e670b45cab2322b518ef7b6f66d30aec0d923dc463e467091a790f67796b9fa71224f2846cebbcf4950c11e040ee124d30f6e164bcd5": 10000000000, + "00c70b8421617802d3f23956cab1957e1d306cd4808589b41760e97927ebfd6053ba12b38288b2b6d5d4c4618d6a8ce59d50580e9c6f704af5": 10000000000, + "00c0933b8238f6f3332e48c34cf1a8e0555943b33cd4abc53aefb7d6124b7ce40dd496bdc02b34602f3a773ff7cccee873991e4c8866f3a70b": 10000000000, + "0069f7d7289de2f01cd1e0265ac5be943b41775abae0ce6b3eac0edee0ce9cadb7cdec2bded3ef8a7bbe3352869bfc1387754c9ee6b1782d9c": 10000000000, + "00709a7070005c652c27df73dbbde3319a90b127bea96aded1c5fb87a59c51dbcf90fa890174497f3f66a0dad06eb7f131e06567995e9c50a5": 10000000000, + "00fc576df3a279885a7a4d0fc500372daa1d96f26c6763542ecd2ad8551753024adea37c134edebb68dc0cfaed5a7009e8305fe1fed8d0ccd1": 10000000000, + "003346a630e6972bf38cce87219db1d63061e7cd324cad88c18e504f2990cac68e973f51256ca938683fa4ea12173d7d047d940fbb883bd0e8": 10000000000, + "0028b862d001e6a64a02b3560cbc532eab4557593477c39cc523e0b9fc527100898c11e731194171b908aad463770d6cbf7ec8871c4cb1e518": 10000000000, + "005e0e57040b06e9d71e0f28f126262838a68db0b52b4fd1b3877dda2203d5d7d4f19c5ee3a1ed51bb670779de19d40aaff2e5e9468cc05c5e": 10000000000, + "00367f65ab69b1e6715c8d5a14964214c9505ed17032266b3209a2c40dcbae9a2a881e603ff39d36e987bacfb87ee98051f222c5fe3efd350c": 10000000000, + "00c5c4ca287f3b53948b5468e5e23b1c87fe61ce52c0d9afd65d070795038751a619d463e91eaed0a774ebdb2f8e12a01a378a153bc3627323": 10000000000, + "00ef198fb7c35e1968308a0b75cfee54a46e13e86dd3354283300831d624165c357b5a0413906a0bfea8ba57587331f0836a186d632ed041b8": 10000000000, + "60a0f1aa7dca95017c11e7e373aebcf0c4568cf47ec12b94f8eb5bba8b": 3000000000000000, + "60ba957a0fff6816021b2afa7900beea68fd10f2d78fb5b64de0d2379c": 3000000000000000, + "007290ea8fa9433c1045a4c8473959ad608e6c03a58c7de33bdbd3ce6f295b987135610616f3c74e11c94d77b6ced5ccc93a7d719cfb135062": 300000000000, + "605276322ac7882434173dcc6441905f6737689bd309b68ad8b3614fd8": 3000000000000000 + }, + "maxKESEvolutions": 60, + "maxLovelaceSupply": 45000000000000000, + "networkId": "Testnet", + "networkMagic": 42, + "protocolParams": { + "a0": 0, + "decentralisationParam": 0, + "eMax": 18, + "extraEntropy": { + "tag": "NeutralNonce" + }, + "keyDeposit": 2000000, + "maxBlockBodySize": 90112, + "maxBlockHeaderSize": 1100, + "maxTxSize": 16384, + "minFeeA": 44, + "minFeeB": 155381, + "minPoolCost": 170000000, + "minUTxOValue": 1000000, + "nOpt": 100, + "poolDeposit": 500000000, + "protocolVersion": { + "major": 10, + "minor": 2 + }, + "rho": 0.003, + "tau": 0.2 + }, + "securityParam": 300, + "slotLength": 1, + "slotsPerKESPeriod": 129600, + "staking": { + "pools": { + "7301761068762f5900bde9eb7c1c15b09840285130f5b0f53606cc57": { + "cost": 340000000, + "margin": 0, + "metadata": null, + "owners": [], + "pledge": 0, + "publicKey": "7301761068762f5900bde9eb7c1c15b09840285130f5b0f53606cc57", + "relays": [], + "rewardAccount": { + "credential": { + "keyHash": "11a14edf73b08a0a27cb98b2c57eb37c780df18fcfcf6785ed5df84a" + }, + "network": "Testnet" + }, + "vrf": "c2b62ffa92ad18ffc117ea3abeb161a68885000a466f9c71db5e4731d6630061" + } + }, + "stake": { + "295b987135610616f3c74e11c94d77b6ced5ccc93a7d719cfb135062": "7301761068762f5900bde9eb7c1c15b09840285130f5b0f53606cc57" + } + }, + "systemStart": "2025-11-12T16:02:17Z", + "updateQuorum": 1 +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC1155Dev.sol b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC1155Dev.sol new file mode 100644 index 000000000..3a385fc1f --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC1155Dev.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +// THIS IS A SAMPLE CONTRACT +// DO NOT USE THIS CONTRACT IN PRODUCTION +contract erc1155 is ERC1155, Ownable { + + constructor() + ERC1155("http://localhost:9999/metadata/erc1155dev/{id}.json") + Ownable(msg.sender) // Pass the initial owner to the Ownable constructor + { + } + + function mint(address _to, uint256 _amount, uint256 _tokenId, bytes memory _data) external { + _mint(_to, _tokenId, _amount, _data); + } +} diff --git a/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC20Dev.sol b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC20Dev.sol new file mode 100644 index 000000000..e3beea0fa --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC20Dev.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +// THIS IS A SAMPLE CONTRACT +// DO NOT USE THIS CONTRACT IN PRODUCTION +contract erc20 is ERC20 { + constructor() ERC20("Mock ERC20", "MERC") {} + + function mint(address _to, uint256 _amount) external { + _mint(_to, _amount); + } +} diff --git a/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC721Dev.sol b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC721Dev.sol new file mode 100644 index 000000000..65e8c2962 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/contract-samples/ERC721Dev.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +// THIS IS A SAMPLE CONTRACT +// DO NOT USE THIS CONTRACT IN PRODUCTION +contract erc721 is ERC721 { + constructor() ERC721("Mock ERC721", "MERC") {} + + function mint(address _to, uint256 _tokenId) external { + _mint(_to, _tokenId); + } +} diff --git a/packages/chains/evm-hardhat/src/scaffold/contract-samples/EffectStreamL2Dev.sol b/packages/chains/evm-hardhat/src/scaffold/contract-samples/EffectStreamL2Dev.sol new file mode 100644 index 000000000..f4a1b89e3 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/contract-samples/EffectStreamL2Dev.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {PaimaL2Contract} from "@paimaexample/evm-contracts/src/contracts/PaimaL2Contract.sol"; + +// THIS IS A SAMPLE CONTRACT +// DO NOT USE THIS CONTRACT IN PRODUCTION +contract effectstreaml2 is PaimaL2Contract { + constructor() PaimaL2Contract(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 0) {} +} diff --git a/packages/chains/evm-hardhat/src/scaffold/contract-samples/EmptyContract.sol b/packages/chains/evm-hardhat/src/scaffold/contract-samples/EmptyContract.sol new file mode 100644 index 000000000..03347aa64 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/contract-samples/EmptyContract.sol @@ -0,0 +1,22 @@ + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +contract emptycontract { + + /// @dev Example counter value. + int private count = 0; + + /// @dev Emitted when the counter is incremented. This get's captured by Effectstream. + event changedCount(address indexed userAddress, int count); + + /// @dev Increments the counter and emits the `changedCount` event. + function incrementCounter() public { + count += 1; + emit changedCount(msg.sender, count); + } + + function getCount() public view returns (int) { + return count; + } +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/mod.ts b/packages/chains/evm-hardhat/src/scaffold/mod.ts new file mode 100644 index 000000000..1398b2f26 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/mod.ts @@ -0,0 +1,2 @@ +export * from './scaffold-evm.ts'; +export * from './scaffold-options.ts'; \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/scaffold-evm.ts b/packages/chains/evm-hardhat/src/scaffold/scaffold-evm.ts new file mode 100644 index 000000000..0c886316d --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/scaffold-evm.ts @@ -0,0 +1,223 @@ +import * as path from "@std/path"; +import { evmContractOptions } from "./scaffold-options.ts"; +import { copyFiles } from "./scaffold-helpers.ts"; +import { currentDir } from "./scaffold-helpers.ts"; + +/* + * This module is responsible for scaffolding a new project. + * +Expected structure. + evm-root + |- ignition + | |- modules + | |- contract1.ts + | |- contract2.ts + | |- ... + |- src/contracts + | |- contract1.sol + | |- contract2.sol + | |- ... + |- deploy.ts + |- hardhat.config.ts + |- package.json + |- foundry.toml + |- deno.json + |- .gitignore + |- README.md + * + */ + +export async function scaffoldEVMProject( + targetFolder: string, + packageName: string, + version: string, + contracts: { + safeCodeName: string, + safePackageName: string, + }[] +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/evm-contracts`; + + const folders = [ + [""], + ]; + + const evmModules: string[] = []; + const evmModulesImports: string[] = []; + for (const contract of contracts) { + evmModules.push( + `{ + module: ${contract.safePackageName}Module, + network: "evmMainHttp", + }` + ); + // import ExampleContractModule from "./ignition/modules/example-contract-module.ts"; + evmModulesImports.push( + `import ${contract.safeCodeName}Module from "./ignition/modules/${contract.safePackageName}-module.ts";` + ); + } + + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles( + path.join(currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), + { + replacements: { + "scope": packageName, + "EFFECTSTREAM-VERSION": version + }, + codeInsertions: { + "EVM-MODULES": evmModules.join(",\n"), + "EVM-MODULES-IMPORT": evmModulesImports.join("\n"), + } + } + ); + } + + for (const contract of contracts) { + await scaffoldEVMContract( + targetFolder, + contract.safeCodeName, + contract.safePackageName + ); + } + + return { + name: fullPackageName, + path: targetFolder + } +}; + +export function evmPrimitiveBlock(contract: string, contractPackageName: string): string { + const option = evmContractOptions.find(o => o.value === contract); + if (!option) { + throw new Error(`Contract option ${contract} not found`); + } + return ` + .addPrimitive( + (syncProtocols) => syncProtocols.mainEvmRPC, + (network, deployments, syncProtocol) => ({ + name: "primitive_${option.value}", + type: builtin.${option.builtInPrimitive}, + startBlockHeight: 0, + contractAddress: contractAddressesEvmMain() + .chain31337["${contractPackageName}Module#${contractPackageName}"], + stateMachinePrefix: \`event_evm_${option.value}\`, + }) + ) + `; +} + +export function evmGrammar(contract: string): { + customGrammar: string; + builtInGrammar: string; +} { + const option = evmContractOptions.find(o => o.value === contract); + if (!option) { + throw new Error(`Contract option ${contract} not found`); + } + + if (option.builtInGrammar === '') { + return { + builtInGrammar: '', + customGrammar: ` + state_${contract}: [ + ["input_a", Type.Integer()], + ["input_b", Type.Integer()], + ], + `, + } + } + + return { + builtInGrammar: `"event_evm_${option.value}": builtinGrammars.${option.builtInGrammar},`, + customGrammar: '', + } +} + +export function evmStateMachine(contract: string): string { + const option = evmContractOptions.find(o => o.value === contract); + if (!option) { + throw new Error(`Contract option ${contract} not found`); + } + + if (option.builtInGrammar === '') { + return ` + stm.addStateTransition("state_${contract}", function* (data) { + console.log( + "🎉 [EVM:${option.value}] Transaction receipt:", + JSON.stringify(data.parsedInput) + ); + + yield* World.resolve(insertData, { + chain: "evm", + action: "${contract}", + data: JSON.stringify(data.parsedInput), + block_height: data.blockHeight + }); + }); + `; + } + return ` + stm.addStateTransition("event_evm_${option.value}", function* (data) { + console.log( + "🎉 [EVM:${option.value}] Transaction receipt:", + JSON.stringify(data.parsedInput) + ); + }); + `; +} + +async function scaffoldEVMContract( + targetFolder: string, + contractCodeName: string, + contractPackageName: string +): Promise { + const folders = [ + ["ignition", "modules"], + ["src", "contracts"] + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + + const option = evmContractOptions.find(o => o.value === contractPackageName); + if (!option) { + console.error(`Contract option ${contractPackageName} not found`); + Deno.exit(1); + } + + await copyFiles( + path.join(currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), + { + replacements: { + "contractPackageName": contractPackageName, + "contractCodeName": contractCodeName, + }, + replaceFileNames: { + "example-module.ts": `${contractCodeName}-module.ts`, + "example-contract.sol": `${contractCodeName}.sol` + } + } + ); + + // overwrite the contract file with the actual contract code + const contractCode = await Deno.readTextFile(option.file); + await Deno.writeTextFile( + path.join(targetFolder, "src", "contracts", `${contractCodeName}.sol`), + contractCode + ); + } +} diff --git a/packages/chains/evm-hardhat/src/scaffold/scaffold-helpers.ts b/packages/chains/evm-hardhat/src/scaffold/scaffold-helpers.ts new file mode 100644 index 000000000..bad1b94dd --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/scaffold-helpers.ts @@ -0,0 +1,61 @@ +import * as path from "@std/path"; + +// Copy files +export async function copyFiles( + sourceDir: string, + targetDir: string, + config: { + replacements?: Record, + codeBlocks?: Record, + codeInsertions?: Record, + replaceFileNames?: Record, + } = {}, +): Promise { + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(/.rename$/, ""); + if (config.replaceFileNames?.[finalName]) { + finalName = config.replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(config.codeBlocks || {})) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(config.codeInsertions || {})) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(config.replacements || {})) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// Get current directory - this has to be JSR compatible. +export function currentDir(): string { + return path.dirname(path.fromFileUrl(import.meta.url)); +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/scaffold-options.ts b/packages/chains/evm-hardhat/src/scaffold/scaffold-options.ts new file mode 100644 index 000000000..7a91ce9a5 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/scaffold-options.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { currentDir } from "./scaffold-helpers.ts"; +export const evmContractOptions = [ + { + label: "ERC-20", + value: "erc20", + builtInPrimitive: 'PrimitiveTypeEVMERC20', + builtInGrammar: 'evmErc20', + file: path.join(currentDir(), "contract-samples", "ERC20Dev.sol"), + }, + { + label: "ERC-721", + value: "erc721", + builtInPrimitive: 'PrimitiveTypeEVMERC721', + builtInGrammar: 'evmErc721', + file: path.join(currentDir(), "contract-samples", "ERC721Dev.sol"), + }, + { + label: "ERC-1155", + value: "erc1155", + builtInPrimitive: 'PrimitiveTypeEVMERC1155', + builtInGrammar: 'evmErc1155', + file: path.join(currentDir(), "contract-samples", "ERC1155Dev.sol"), + }, + { + label: "Effect-Stream L2", + value: "effectstreaml2", + builtInPrimitive: 'PrimitiveTypeEVMPaimaL2', + builtInGrammar: '', + file: path.join(currentDir(), "contract-samples", "EffectStreamL2Dev.sol"), + }, + // { + // label: "Empty Contract", + // value: "emptycontract", + // builtInPrimitive: 'PrimitiveTypeEVMGeneric', + // builtInGrammar: 'evmGeneric', + // file: path.join(currentDir(), "contract-samples", "EmptyContract.sol"), + // }, +]; diff --git a/packages/chains/evm-hardhat/src/scaffold/template/.gitignore.rename b/packages/chains/evm-hardhat/src/scaffold/template/.gitignore.rename new file mode 100644 index 000000000..9d97692bc --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/.gitignore.rename @@ -0,0 +1,12 @@ +node_modules + +# Auto generated file +mod.ts + +# build +/build + +# deployments +/ignition/deployments + +remappings.txt \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/template/README.md b/packages/chains/evm-hardhat/src/scaffold/template/README.md new file mode 100644 index 000000000..009458226 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/README.md @@ -0,0 +1,82 @@ +# Build Contracts + +`deno task -f @[scope]/evm-contracts build` + +# Deploy Contracts + +`deno task -f @[scope]/evm-contracts deploy:standalone` + +# Setup + +## Setup your EVM Chains + +`hardhat.config.ts` has a section with networks, you can edit to match your requirements. + +```js + networks: { + myNetworkName: { + type: "edr", + chainType: "l1", + chainId: 31337, + mining: { + auto: true, + interval: 250, + }, + allowBlocksWithSameTimestamp: true, + }, + myNetworkNameHttp: { + type: "http", + chainType: "l1", + url: "http://0.0.0.0:8547", + }, + }, + +``` + +Important: + +- You must add two entries for each network. myNetworkName and myNetworkNameHttp. +- The first network will automatically start at port 8545, 8546 for the second and so forward. + +## Create and deploy new Contracts + +To add your contracts you will need 3 steps: + +### 1. Add new Contract + +Add your Solidity Contracts in `/src/contracts/my-contract.sol` +and run `deno task -f @[scope]/evm-contracts build` + +Your contract is compiled and ready to be used. + +### 2. Create Ignition Module + +First create a ignition module at: +`./ignition/modules/my-contract-module.ts` + +With a Hardhat-Ignition Module, for example: + +```ts +export { buildModule } from "@nomicfoundation/ignition-core"; + +export default buildModule("MyModuleName", (m) => { + const contract = m.contract("MyContractName", []); + return { contract }; +}); +``` + +Then in `./deploy.ts` import your created module. + +```ts +const myDeployments: Deployment[] = [ + ..., + { + module: MyModuleName, + network: "evmMainHttp", + } +]; +``` + +### 3. Redeploy Contracts + +Run `deno task -f @[scope]/evm-contracts deploy:standalone` diff --git a/packages/chains/evm-hardhat/src/scaffold/template/deno.json.rename b/packages/chains/evm-hardhat/src/scaffold/template/deno.json.rename new file mode 100644 index 000000000..c1d991c4c --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/deno.json.rename @@ -0,0 +1,66 @@ +{ + "name": "@[scope]/evm-contracts", + "exports": "./mod.ts", + "version": "0.3.0", + "license": "MIT", + "imports": { + "@nomicfoundation/ignition-core": "npm:@nomicfoundation/ignition-core@3.0.2", + "@paimaexample/evm-hardhat": "jsr:@paimaexample/evm-hardhat@[EFFECTSTREAM-VERSION]", + "@paimaexample/log": "jsr:@paimaexample/log@[EFFECTSTREAM-VERSION]", + "@wagmi/cli": "npm:@wagmi/cli@2.3.1", + "hardhat": "npm:hardhat@3.0.4", + "@nomicfoundation/hardhat-ignition-viem": "npm:@nomicfoundation/hardhat-ignition-viem@3.0.2", + "@nomicfoundation/hardhat-ignition": "npm:@nomicfoundation/hardhat-ignition-viem@3.0.2", + "@nomicfoundation/hardhat-viem": "npm:@nomicfoundation/hardhat-viem@3.0.0", + "ws": "npm:ws@8.18.1", + "@nomicfoundation/hardhat-errors": "npm:@nomicfoundation/hardhat-errors@3.0.1", + "@nomicfoundation/hardhat-utils": "npm:@nomicfoundation/hardhat-utils@3.0.0" + }, + "tasks": { + // "swap:remappings:forge": "deno -A @paimaexample/evm-hardhat/remappings-forge --depth 4", + // "swap:remappings:hardhat": "deno -A @paimaexample/evm-hardhat/remappings-hardhat", + "swap:remappings:forge": "cp remappings.forge.txt remappings.txt", + "swap:remappings:hardhat": "cp remappings.hardhat.txt remappings.txt", + + "check": "echo 'check'", + "build:mod": { + "command": "(deno task deploy:standalone || true) && deno run -A @paimaexample/evm-hardhat/builder" + }, + "build:clean": "rm -rf build || true", + "build:forge": { + "command": "forge build", + "dependencies": [ + "swap:remappings:forge" + ] + }, + "build:hardhat": { + "command": "deno run -A npm:hardhat@3.0.4 compile", + "dependencies": [ + "swap:remappings:hardhat" + ] + }, + "build": { + "command": "deno task build:forge && deno task build:hardhat", + "dependencies": [ + "build:clean" + ] + }, + "contract:compile": "deno task build:mod", + "deploy:clean": "rm -rf ignition/deployments || true", + "deploy:standalone": { + "command": "(((deno task chain:start) &) && (deno task chain:wait) && (deno task deploy) && deno task chain:stop) || true", + "dependencies": [ + "build" + ] + }, + "deploy": { + "command": "deno run -A deploy.ts", + "dependencies": [ + "deploy:clean" + ] + }, + "chain:start": "deno run -A npm:hardhat@3.0.4 node", + "chain:stop": "kill -9 $(lsof -ti tcp:8545) || true", + "chain:wait": "deno run -A npm:hardhat@3.0.4 node wait" + } +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/template/deploy.ts.rename b/packages/chains/evm-hardhat/src/scaffold/template/deploy.ts.rename new file mode 100644 index 000000000..83de48088 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/deploy.ts.rename @@ -0,0 +1,70 @@ +// import hre from "hardhat"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; +import * as config from "./hardhat.config.ts"; +import type { buildModule } from "@nomicfoundation/ignition-core"; +/** EVM-MODULES-IMPORT */ + +const __dirname: any = import.meta.dirname; + +type Deployment = { + module: ReturnType; + network: string; + parameters?: Record>; +}; + +// This is an example of how to deploy contracts. +// This is the list of contracts to deploy. +// Add or remove contracts as needed. +const myDeployments: Deployment[] = [ + // Example Erc20 + /* + * { + * module: Erc20DevModule, + * network: "evmMainHttp", + * }, + */ + + // Example PaimaL2 + /* + * { + * module: PaimaL2ContractModule, + * network: "evmMainHttp", + * parameters: { + * PaimaL2ContractModule: { + * owner: "0xEFfE522D441d971dDC7153439a7d10235Ae6301f", + * fee: 0, + * }, + * }, + * }, + */ + + // [CUSTOM-CODE-1] + /** EVM-MODULES */ +] as const; + +/** + * Deploy the contracts to the network. + */ +export async function deploy(): Promise { + const hre = await createHardhatRuntimeEnvironment(config.default, __dirname); + const messages: string[] = []; + for (const deployment of myDeployments) { + const network = await hre.network.connect(deployment.network); + const result = await (network as any).ignition.deploy( + deployment.module, + deployment.parameters ? { parameters: deployment.parameters } : undefined, + ); + messages.push( + `${deployment.module.id.substring(0, 16).padEnd(16)} @ ${ + deployment.network.substring(0, 16).padEnd(16) + } deployed to ${result.contract.address}`, + ); + } + console.log("Deployed contracts:\n", messages.join("\n")); + // Wait for a block to be minted on the slowest chain. + await new Promise((r) => setTimeout(r, 1000 * 2)); +} + +if (import.meta.main) { + await deploy(); +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/template/foundry.toml b/packages/chains/evm-hardhat/src/scaffold/template/foundry.toml new file mode 100644 index 000000000..b1dfdc8c4 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/foundry.toml @@ -0,0 +1,33 @@ +[profile.default] +src = 'src/contracts' # the source directory +test = 'test/src' # the test directory +out = 'build/artifacts/forge' # the output directory (for artifacts) +libs = ['test/lib'] # a list of library directories +cache_path = 'build/cache/forge' +allow_paths = ["../../../../node_modules"] +#libraries = [] # a list of deployed libraries to link against +#cache = true # whether to cache builds or not +#force = false # whether to ignore the cache (clean build) +evm_version = 'berlin' # the evm version (by hardfork name) +#solc_version = '0.8.20' # override for the solc version (setting this ignores `auto_detect_solc`) +#auto_detect_solc = true # enable auto-detection of the appropriate solc version to use +#optimizer = true # enable or disable the solc optimizer +#optimizer_runs = 200 # the number of optimizer runs +#verbosity = 0 # the verbosity of tests +#ignored_error_codes = [] # a list of ignored solc error codes +#fuzz_runs = 256 # the number of fuzz runs for tests +#ffi = false # whether to enable ffi or not +#sender = '0x00a329c0648769a73afac7f9381e08fb43dbea72' # the address of `msg.sender` in tests +#tx_origin = '0x00a329c0648769a73afac7f9381e08fb43dbea72' # the address of `tx.origin` in tests +#initial_balance = '0xffffffffffffffffffffffff' # the initial balance of the test contract +#block_number = 0 # the block number we are at in tests +#chain_id = 99 # the chain id we are on in tests +#gas_limit = 9223372036854775807 # the gas limit in tests +#gas_price = 0 # the gas price (in wei) in tests +#block_base_fee_per_gas = 0 # the base fee (in wei) in tests +#block_coinbase = '0x0000000000000000000000000000000000000000' # the address of `block.coinbase` in tests +#block_timestamp = 0 # the value of `block.timestamp` in tests +#block_difficulty = 0 # the value of `block.difficulty` in tests +[invariant] +fail_on_revert = true +depth = 15 \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/template/hardhat.config.ts.rename b/packages/chains/evm-hardhat/src/scaffold/template/hardhat.config.ts.rename new file mode 100644 index 000000000..71201549c --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/hardhat.config.ts.rename @@ -0,0 +1,44 @@ +import type { HardhatUserConfig } from "hardhat/config"; +import { + createHardhatConfig, + createNodeTasks, + initTelemetry, +} from "@paimaexample/evm-contracts"; +import { + JsonRpcServerImplementation, +} from "@paimaexample/evm-hardhat/json-rpc-server"; +import fs from "node:fs"; +import waitOn from "wait-on"; +import { + ComponentNames, + log, + SeverityNumber, +} from "@paimaexample/log"; + +const __dirname: any = import.meta.dirname; + +// Initialize telemetry +initTelemetry("@paimaexample/log", "./deno.json"); + +// Create node tasks +const nodeTasks = createNodeTasks({ + JsonRpcServer: {} as unknown as never, // Type placeholder, not used + JsonRpcServerImplementation, + ComponentNames, + log, + SeverityNumber, + waitOn, + fs, +}); + +// Create unified config with default networks +const config: HardhatUserConfig = createHardhatConfig({ + sourcesDir: `${__dirname}/src/contracts`, + artifactsDir: `${__dirname}/build/artifacts/hardhat`, + cacheDir: `${__dirname}/build/cache/hardhat`, + // Default networks (evmMain, evmMainHttp, evmParallel, evmParallelHttp) are used automatically + tasks: nodeTasks, + solidityVersion: "0.8.30", +}); + +export default config; diff --git a/packages/chains/evm-hardhat/src/scaffold/template/ignition/modules/example-module.ts.rename b/packages/chains/evm-hardhat/src/scaffold/template/ignition/modules/example-module.ts.rename new file mode 100644 index 000000000..9dd023636 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/ignition/modules/example-module.ts.rename @@ -0,0 +1,6 @@ +import { buildModule } from "@nomicfoundation/ignition-core"; + +export default buildModule("[contractPackageName]Module", (m) => { + const contract = m.contract("[contractPackageName]"); + return { contract }; +}); diff --git a/packages/chains/evm-hardhat/src/scaffold/template/package.json.rename b/packages/chains/evm-hardhat/src/scaffold/template/package.json.rename new file mode 100644 index 000000000..55c053caa --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/package.json.rename @@ -0,0 +1,14 @@ +{ + "type": "module", + "dependencies": { + "@openzeppelin/contracts": "5.1.0", + "@openzeppelin/contracts-upgradeable": "^5.1.0", + "@paimaexample/evm-contracts": "[EFFECTSTREAM-VERSION]" + }, + "devDependencies": { + "jsonc-parser": "3.3.1", + "@opentelemetry/sdk-node": "0.56.0", + "hardhat": "3.0.4", + "wait-on": "8.0.3" + } +} \ No newline at end of file diff --git a/packages/chains/evm-hardhat/src/scaffold/template/remappings.forge.txt b/packages/chains/evm-hardhat/src/scaffold/template/remappings.forge.txt new file mode 100644 index 000000000..580beb79e --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/remappings.forge.txt @@ -0,0 +1,2 @@ +@openzeppelin/=../../../../node_modules/@openzeppelin/ +@paimaexample/=../../../../node_modules/@paimaexample/ diff --git a/packages/chains/evm-hardhat/src/scaffold/template/remappings.hardhat.txt b/packages/chains/evm-hardhat/src/scaffold/template/remappings.hardhat.txt new file mode 100644 index 000000000..b956ec71f --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/remappings.hardhat.txt @@ -0,0 +1,2 @@ +@openzeppelin/=node_modules/@openzeppelin/ +@paimaexample/=node_modules/@paimaexample/ diff --git a/packages/chains/evm-hardhat/src/scaffold/template/src/contracts/example-contract.sol b/packages/chains/evm-hardhat/src/scaffold/template/src/contracts/example-contract.sol new file mode 100644 index 000000000..4b6370a02 --- /dev/null +++ b/packages/chains/evm-hardhat/src/scaffold/template/src/contracts/example-contract.sol @@ -0,0 +1 @@ +// THIS FILE WILL BE REPLACED WITH THE CONTRACT CODE \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/mod.ts b/packages/chains/midnight-contracts/src/scaffold/mod.ts new file mode 100644 index 000000000..474c47133 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/mod.ts @@ -0,0 +1,3 @@ +export * from './scaffold-midnight.ts'; +export * from './scaffold-midnight-contract.ts'; +export * from './scaffold-options.ts'; diff --git a/packages/chains/midnight-contracts/src/scaffold/scaffold-helpers.ts b/packages/chains/midnight-contracts/src/scaffold/scaffold-helpers.ts new file mode 100644 index 000000000..61ea9bed9 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/scaffold-helpers.ts @@ -0,0 +1,75 @@ +import * as path from "@std/path"; + +export function joinFile(...parts: string[]): string { + const filePath = path.join(...parts); + try { + const exists = Deno.statSync(filePath); + if (!exists.isFile) { + throw new Error(`File ${filePath} is not a file`); + } + return filePath; + } catch (error) { + throw new Error(`File ${filePath} does not exist`); + } +} + +// Copy files +export async function copyFiles( + sourceDir: string, + targetDir: string, + config: { + replacements?: Record, + codeBlocks?: Record, + codeInsertions?: Record, + replaceFileNames?: Record, + } = {}, +): Promise { + // console.log("Copying files from", sourceDir, "to", targetDir); + const files = await Deno.readDir(sourceDir); + await Deno.mkdir(targetDir, { recursive: true }); + + for await (const file of files) { + if (file.isDirectory) { + continue; + } + let finalName = file.name.replace(".rename", ""); + if (config.replaceFileNames?.[finalName]) { + finalName = config.replaceFileNames[finalName]; + } + + let content = await Deno.readTextFile(path.join(sourceDir, file.name)); + + // Enable/disable entire inlined code blocks + for (const [codeBlock, enabled] of Object.entries(config.codeBlocks || {})) { + // Search for block of code between /** TAG */ ... /** TAG */ + const r = `\\/\\*\\* ${codeBlock} \\*\\/([\\s\\S]+?)\\/\\*\\* ${codeBlock} \\*\\/`; + const regex = new RegExp(r, 'g'); + if (enabled) { + content = content.replace(regex, `$1`); + } else { + content = content.replace(regex, ''); + } + } + + // Insert dynamically generated code blocks + for (const [r, code] of Object.entries(config.codeInsertions || {})) { + // Search for tags /** TAG */ + const regex = new RegExp(`\\/\\*\\* ${r} \\*\\/`, 'g'); + content = content.replace(regex, code); + } + + // Replace placeholders with actual values + for (const [r, replacement] of Object.entries(config.replacements || {})) { + // Replace tags in [TAG] format + const regex = new RegExp(`\\[${r}\\]`, 'g'); + content = content.replace(regex, replacement); + } + + await Deno.writeTextFile(path.join(targetDir, finalName), content); + } +} + +// Get current directory - this has to be JSR compatible. +export function currentDir(): string { + return path.dirname(path.fromFileUrl(import.meta.url)); +} \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight-contract.ts b/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight-contract.ts new file mode 100644 index 000000000..e19fd3348 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight-contract.ts @@ -0,0 +1,124 @@ +import * as path from "@std/path"; +import { copyFiles } from "./scaffold-helpers.ts"; +import { currentDir } from "./scaffold-helpers.ts"; + +/** + * This module is responsible for scaffolding a new midnight contract. + * + * @param targetFolder - root where project will be scaffolded. + * @param packageName - name of the parent package. + * @param contractCodeName - someSafe_nameForJSCode + * @param contractPackageName - some-safe-name-for-package + * @param version - version of the effectstream packages + * @returns - name and path of the scaffolded contract package + */ +export async function scaffoldMidnightContract( + targetFolder: string, + packageName: string, + contractCodeName: string, + contractPackageName: string, + contractFile: string, + version: string +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/midnight-contract-${contractPackageName}`; + + const folders = [ + [""], + ["src"], + ["src", "base-contracts", "src", "access"], + ["src", "base-contracts", "src", "security"], + ["src", "base-contracts", "src", "token"], + ["src", "base-contracts", "src", "utils"], + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles( + path.join(currentDir(), "template", "contract-template", ...folder), + path.join(targetFolder, ...folder), { + replacements: { + "scope": packageName, + "contract-name": contractPackageName, + "EFFECTSTREAM-VERSION": version, + "contract-code-name": contractCodeName, + }, + replaceFileNames: { + "contract-name.compact": `${contractPackageName}.compact`, + } + }); + } + + await Deno.copyFile( + path.join(contractFile), + path.join(targetFolder, "src", `${contractPackageName}.compact`), + ); + + return { + name: fullPackageName, + path: targetFolder + } +}; + + +if (import.meta.main) { + function checkInputs(args: string[]): { + targetFolder: string, + packageName: string, + contractCodeName: string, + contractPackageName: string, + version: string + } { + const targetFolder = args[0]; + const packageName = args[1]; + const contractCodeName = args[2]; + const contractPackageName = args[3]; + const version = args[4]; + if (!targetFolder) { + console.error("Target folder is required"); + Deno.exit(1); + } + if (!version) { + console.error("Version is required"); + Deno.exit(1); + } + if (!contractCodeName) { + console.error("Contract code name is required"); + Deno.exit(1); + } + if (!contractPackageName) { + console.error("Contract package name is required"); + Deno.exit(1); + } + if (!packageName) { + console.error("Package name is required"); + Deno.exit(1); + } + if (!contractFileName) { + console.error("Contract file name is required"); + Deno.exit(1); + } + return { + targetFolder: targetFolder.trim(), + packageName: packageName.trim(), + contractCodeName: contractCodeName.trim(), + contractPackageName: contractPackageName.trim(), + contractFileName: contractFileName.trim(), + version: version.trim() + }; + } + const { targetFolder, packageName, contractCodeName, contractPackageName, contractFileName, version } = checkInputs(Deno.args); + await scaffoldMidnightContract( + targetFolder, + packageName, + contractCodeName, + contractPackageName, + contractFileName, + version + ); +} diff --git a/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight.ts b/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight.ts new file mode 100644 index 000000000..307b7a509 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/scaffold-midnight.ts @@ -0,0 +1,189 @@ +import * as path from "@std/path"; +import { copyFiles } from "./scaffold-helpers.ts"; +import { currentDir } from "./scaffold-helpers.ts"; + +/* + * This module is responsible for scaffolding a new project. + */ +export async function scaffoldMidnightProject( + targetFolder: string, + packageName: string, + version: string, + contracts: { + safeCodeName: string, + safePackageName: string, + }[] +): Promise<{ + name: string; + path: string; +}> { + const fullPackageName = `@${packageName}/midnight-contracts`; + + const folders = [ + [""], + ["indexer-standalone"], + ]; + + for (const folder of folders) { + await Deno.mkdir(path.join(targetFolder, ...folder), { recursive: true }); + } + + for (const folder of folders) { + await copyFiles( + path.join(currentDir(), "template", ...folder), + path.join(targetFolder, ...folder), { + replacements: { + "scope": packageName, + "EFFECTSTREAM-VERSION": version + }, + codeInsertions: { + "MIDNIGHT-DEPLOY-IMPORTS": contracts + .map(({ safeCodeName, safePackageName }) => importDeployContract(safeCodeName, safePackageName)) + .join("\n"), + "MIDNIGHT-DEPLOY-CONFIG": contracts + .map(({ safeCodeName, safePackageName }) => deployContract(safeCodeName, safePackageName)) + .join(",\n"), + }, + } + ); + } + + return { + name: fullPackageName, + path: targetFolder + } +}; + +export function getReadContractCode(safePackageName: string): string { + return ` + readMidnightContract("${safePackageName}", "contract-${safePackageName}.json") + `; +} + +export function contractBatcher(_safeCodeContractName: string, safePackageName: string, index: number): string { + const safeCodeContractName = _safeCodeContractName + 'Contract'; + const infoImport = _safeCodeContractName + 'Info'; + return ` + /** MIDNIGHT-READ-CONTRACT-BLOCK */ + const midnightAdapterConfig${index} = { + indexer, + indexerWS, + node, + proofServer, + zkConfigPath: zkConfigPath${index}, + privateStateStoreName: "private-state-${safeCodeContractName}", // Local LevelDB store + privateStateId: "${safeCodeContractName}PrivateState", // On-chain contract ID (must match deploy.ts) + } + export const midnightAdapter_${_safeCodeContractName} = new MidnightAdapter( + contractAddress${index}, + GENESIS_MINT_WALLET_SEED, + midnightAdapterConfig${index}, + new ${safeCodeContractName}.Contract(${infoImport}.witnesses), + ${infoImport}.witnesses, + contractInfo${index}, + networkID, + syncProtocolName, + ); + `; +} + + +export function midnightPrimitiveBlock(safeCodeContractName: string, safePackageName: string): string { + return ` + .addPrimitive( + (syncProtocols) => syncProtocols.parallelMidnight, + (network, deployments, syncProtocol) => ({ + name: "primitive_${safePackageName}", + type: builtin.PrimitiveTypeMidnightGeneric, + startBlockHeight: 1, + contractAddress: readMidnightContract("${safePackageName}", "contract-${safePackageName}.json").contractAddress, + stateMachinePrefix: "event_midnight_${safePackageName}", + contract: { ledger: ${safeCodeContractName}Contract.ledger }, + networkId: 0, + }) + ) + `; +} + +export function midnightGrammar(safePackageName: string): { + customGrammar: string; + builtInGrammar: string; +} { + return { + builtInGrammar: `"event_midnight_${safePackageName}": builtinGrammars.midnightGeneric,`, + customGrammar: '', + } +} + +export function midnightStateMachine(safePackageName: string): string { + return ` + stm.addStateTransition("event_midnight_${safePackageName}", function* (data) { + console.log( + "🎉 [MIDNIGHT:${safePackageName}] Transaction receipt:", + JSON.stringify(data.parsedInput.payload) + ); + yield* World.resolve(insertData, { + chain: "midnight", + action: "${safePackageName}", + data: JSON.stringify(data.parsedInput.payload), + block_height: data.blockHeight + }); + }); +`; +} + +const importDeployContract = (contractCodeName: string, contractPackageName: string) => { + return ` + import { + ${contractCodeName}, + witnesses as ${contractCodeName}Witnesses, + } from "./${contractPackageName}/src/index.original.ts"; + ` +} +const deployContract = ( + contractCodeName: string, + contractPackageName: string, +) => { + return ` + { + contractName: "${contractPackageName}", + contractFileName: "contract-${contractPackageName}.json", + contractClass: ${contractCodeName}.Contract, + witnesses: ${contractCodeName}Witnesses, + privateStateId: "${contractCodeName}State", + initialPrivateState: {}, + deployArgs: [], + privateStateStoreName: "${contractPackageName}-private-state", + extractWalletAddress: true, // Extract wallet address and replace last arg with initialOwner + } +`; +} + +if (import.meta.main) { + function checkInputs(args: string[]): { targetFolder: string, packageName: string, version: string } { + const targetFolder = args[0]; + const packageName = args[1]; + const version = args[2]; + if (!targetFolder) { + console.error("Target folder is required"); + Deno.exit(1); + } + if (!packageName) { + console.error("Package name is required"); + Deno.exit(1); + } + if (!version) { + console.error("Version is required"); + Deno.exit(1); + } + return { + targetFolder: targetFolder.trim(), + packageName: packageName.trim(), + version: version.trim() + }; + } + + checkInputs(Deno.args); + const { targetFolder, packageName, version } = checkInputs(Deno.args); + await scaffoldMidnightProject(targetFolder, packageName, version, []); +} diff --git a/packages/chains/midnight-contracts/src/scaffold/scaffold-options.ts b/packages/chains/midnight-contracts/src/scaffold/scaffold-options.ts new file mode 100644 index 000000000..bdfe32931 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/scaffold-options.ts @@ -0,0 +1,23 @@ +import { currentDir, joinFile } from "./scaffold-helpers.ts"; +export const midnightContractOptions = [ + { + label: "ERC-20", + value: "unshielded-erc20", + file: joinFile(currentDir(), "template", "contract-template", "_contracts", "erc20.compact"), + }, + // { + // label: "ERC-721", + // value: "unshielded-erc721", + // file: joinFile(currentDir(), "template", "contract-template", "_contracts", "erc721.compact"), + // }, + // { + // label: "ERC-1155", + // value: "unshielded-erc1155", + // file: joinFile(currentDir(), "template", "contract-template", "_contracts", "erc1155.compact"), + // }, + // { + // label: "Empty Contract", + // value: "empty-contract", + // file: joinFile(currentDir(), "template", "contract-template", "_contracts", "empty.compact"), + // }, +]; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/.gitignore.rename b/packages/chains/midnight-contracts/src/scaffold/template/.gitignore.rename new file mode 100644 index 000000000..a6d733370 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/.gitignore.rename @@ -0,0 +1,3 @@ +midnight-level-db/ +contract-eip-1155/src/managed +contract.json \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/empty.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/empty.compact new file mode 100644 index 000000000..14f8e1db1 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/empty.compact @@ -0,0 +1,11 @@ +pragma language_version >= 0.16; + +import CompactStandardLibrary; + +// public state +export ledger round: Counter; + +// transition function changing public state +export circuit increment(): [] { + round.increment(1); +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc1155.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc1155.compact new file mode 100644 index 000000000..64a23e9fa --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc1155.compact @@ -0,0 +1,106 @@ +pragma language_version >= 0.16.0; + +import CompactStandardLibrary; +import "./base-contracts/src/token/MultiToken" + prefix MultiToken_; + +export ledger effectstream_action_name: Uint<128>; +export ledger effectstream_action_id: Uint<128>; +export ledger effectstream_action_target: Either; +export ledger effectstream_action_target2: Either; +export ledger effectstream_action_value: Uint<128>; +export ledger effectstream_action_initiator: Either; + +constructor( + // uri_: Opaque<"string">, +) { + const uri: Opaque<"string"> = default>; + MultiToken_initialize(uri); +} + +export circuit uri(id: Uint<128>): Opaque<"string"> { + return MultiToken_uri(id); +} + +export circuit balanceOf( + account: Either, + id: Uint<128> +): Uint<128> { + return MultiToken_balanceOf(account, id); +} + +export circuit setApprovalForAll( + operator: Either, + approved: Boolean +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1006; + effectstream_action_target = disclose(operator); + if (disclose(approved)) { + effectstream_action_value = 1; + } else { + effectstream_action_value = 0; + } + effectstream_action_initiator = left(ownPublicKey()); + + MultiToken_setApprovalForAll(operator, approved); +} + +export circuit isApprovedForAll( + account: Either, + operator: Either +): Boolean { + return MultiToken_isApprovedForAll(account, operator); +} + +export circuit transferFrom( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1005; + effectstream_action_target = disclose(from); + effectstream_action_target2 = disclose(to); + effectstream_action_id = disclose(id); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + MultiToken_transferFrom(from, to, id, value); +} + +export circuit mint( + to: Either, + id: Uint<128>, + value: Uint<128>, +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1001; + effectstream_action_target = disclose(to); + effectstream_action_id = disclose(id); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + MultiToken__mint(to, id, value); +} + +export circuit burn( + from: Either, + id: Uint<128>, + value: Uint<128>, +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1010; + effectstream_action_target = disclose(from); + effectstream_action_id = disclose(id); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + MultiToken__burn(from, id, value); +} + diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc20.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc20.compact new file mode 100644 index 000000000..94fd78cc2 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc20.compact @@ -0,0 +1,119 @@ +pragma language_version >= 0.16.0; + +import CompactStandardLibrary; +import "./base-contracts/src/token/FungibleToken" + prefix FungibleToken_; + +export ledger effectstream_action_name: Uint<128>; +export ledger effectstream_action_target: Either; +export ledger effectstream_action_target2: Either; +export ledger effectstream_action_value: Uint<128>; +export ledger effectstream_action_initiator: Either; + +constructor( +// name: Opaque<"string">, +// symbol: Opaque<"string">, +// decimals: Uint<8>, +// recipient: Either, +// fixedSupply: Uint<128>, +) { + const name: Opaque<"string"> = default>; + const symbol: Opaque<"string"> = default>; + const decimals: Uint<8> = 8; + FungibleToken_initialize(name, symbol, decimals); + // FungibleToken__mint(recipient, fixedSupply); +} + +export circuit mint( + account: Either, + value: Uint<128>, +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1001; + effectstream_action_target = disclose(account); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + FungibleToken__mint(account, value); +} + +export circuit name(): Opaque<"string"> { + return FungibleToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return FungibleToken_symbol(); +} + +export circuit decimals(): Uint<8> { + return FungibleToken_decimals(); +} + +export circuit totalSupply(): Uint<128> { + return FungibleToken_totalSupply(); +} + +export circuit balanceOf( + account: Either, +): Uint<128> { + return FungibleToken_balanceOf(account); +} + +export circuit transfer( + to: Either, + value: Uint<128>, +): Boolean { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1002; + effectstream_action_target = disclose(to); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + return FungibleToken_transfer(to, value); +} + +export circuit approve( + spender: Either, + value: Uint<128>, +): Boolean { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1003; + effectstream_action_target = disclose(spender); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + return FungibleToken_approve(spender, value); +} + +export circuit allowance( + owner: Either, + spender: Either, +): Uint<128> { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1004; + effectstream_action_target = disclose(owner); + effectstream_action_target2 = disclose(spender); + effectstream_action_initiator = left(ownPublicKey()); + + return FungibleToken_allowance(owner, spender); +} + +export circuit transferFrom( + from: Either, + to: Either, + value: Uint<128>, +): Boolean { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1005; + effectstream_action_target = disclose(from); + effectstream_action_target2 = disclose(to); + effectstream_action_value = disclose(value); + effectstream_action_initiator = left(ownPublicKey()); + + return FungibleToken_transferFrom(from, to, value); +} \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc721.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc721.compact new file mode 100644 index 000000000..ce66db0d2 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/_contracts/erc721.compact @@ -0,0 +1,143 @@ +pragma language_version >= 0.16.0; + +import CompactStandardLibrary; +import "./base-contracts/src/token/NonFungibleToken" + prefix NonFungibleToken_; + +export ledger effectstream_action_name: Uint<128>; +export ledger effectstream_action_id: Uint<128>; +export ledger effectstream_action_target: Either; +export ledger effectstream_action_target2: Either; +export ledger effectstream_action_value: Uint<128>; +export ledger effectstream_action_initiator: Either; + +constructor( + // name: Opaque<"string">, + // symbol: Opaque<"string">, +) { + const name: Opaque<"string"> = default>; + const symbol: Opaque<"string"> = default>; + NonFungibleToken_initialize(name, symbol); +} + +export circuit name(): Opaque<"string"> { + return NonFungibleToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NonFungibleToken_symbol(); +} + +export circuit balanceOf( + owner: Either +): Uint<128> { + return NonFungibleToken_balanceOf(owner); +} + +export circuit ownerOf( + tokenId: Uint<128> +): Either { + return NonFungibleToken_ownerOf(tokenId); +} + +export circuit tokenURI( + tokenId: Uint<128> +): Opaque<"string"> { + return NonFungibleToken_tokenURI(tokenId); +} + +export circuit setTokenURI( + tokenId: Uint<128>, + uri: Opaque<"string"> +): [] { + // No action tracking for URI setting for now, or maybe generic? + // Following minimal pattern for now. + NonFungibleToken__setTokenURI(tokenId, uri); +} + +export circuit approve( + to: Either, + tokenId: Uint<128> +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1003; + effectstream_action_target = disclose(to); + effectstream_action_id = disclose(tokenId); + effectstream_action_initiator = left(ownPublicKey()); + + NonFungibleToken_approve(to, tokenId); +} + +export circuit getApproved( + tokenId: Uint<128> +): Either { + return NonFungibleToken_getApproved(tokenId); +} + +export circuit setApprovalForAll( + operator: Either, + approved: Boolean +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1006; + effectstream_action_target = disclose(operator); + if (disclose(approved)) { + effectstream_action_value = 1; + } else { + effectstream_action_value = 0; + } + effectstream_action_initiator = left(ownPublicKey()); + + NonFungibleToken_setApprovalForAll(operator, approved); +} + +export circuit isApprovedForAll( + owner: Either, + operator: Either +): Boolean { + return NonFungibleToken_isApprovedForAll(owner, operator); +} + +export circuit transferFrom( + from: Either, + to: Either, + tokenId: Uint<128> +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1005; + effectstream_action_target = disclose(from); + effectstream_action_target2 = disclose(to); + effectstream_action_id = disclose(tokenId); + effectstream_action_initiator = left(ownPublicKey()); + + NonFungibleToken_transferFrom(from, to, tokenId); +} + +export circuit mint( + to: Either, + tokenId: Uint<128> +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1001; + effectstream_action_target = disclose(to); + effectstream_action_id = disclose(tokenId); + effectstream_action_initiator = left(ownPublicKey()); + + NonFungibleToken__mint(to, tokenId); +} + +export circuit burn( + tokenId: Uint<128> +): [] { + // This is to allow effectstream-engine to track the action + // NOTE This will be removed once events are supported + effectstream_action_name = 1010; + effectstream_action_id = disclose(tokenId); + effectstream_action_initiator = left(ownPublicKey()); + + NonFungibleToken__burn(tokenId); +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/convert-js.ts.rename b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/convert-js.ts.rename new file mode 100644 index 000000000..bc5750dac --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/convert-js.ts.rename @@ -0,0 +1,34 @@ +// Convert js files to ts files. +// +// This is a temporal solution for converting the .cjs file to .ts +// This is a partial conversion and might fail for some cases. +// The goal is doing the minimal conversion to make it a valid ts file. +// We expect the compiler to generate the .mjs or .ts eventually. +export async function convertJS(jsPath: string, tsPath: string) { + const jsFile = await Deno.readTextFile(jsPath); + // Add "// @ts-nocheck" to the top of the file. + const output = "// @ts-nocheck\n" + jsFile + // 1. Remove 'use strict'. + .replace(/^["']use strict["'];?/, "") + // 2. Replace const r = require('lib') with import * as r from 'lib'. + .replace( + /(const|let|var) (\w+) = require\(['"](.*)["']\);?/g, + "import * as $2 from '$3';", + ) + // replace exports.foo = foo; with export { foo }; + .replace(/exports\.(\w+) = (\w+);?/g, "export { $1 };"); + + await Deno.writeTextFile(tsPath, output); + console.log(`Converted ${jsPath} to ${tsPath}`); +} + +if (import.meta.main) { + const args = Deno.args; + if (args.length !== 2) { + console.error( + "Usage: deno run -A convert-js.ts ", + ); + Deno.exit(1); + } + await convertJS(args[0], args[1]); +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/deno.json.rename b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/deno.json.rename new file mode 100644 index 000000000..86aebfc16 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/deno.json.rename @@ -0,0 +1,16 @@ +{ + "name": "@[scope]/midnight-contract-[contract-name]", + "exports": { + ".": "./src/_index.ts", + "./witnesses": "./src/witnesses.ts", + "./contract": "./src/managed/[contract-name]/contract/index.cjs" + }, + "tasks": { + "compact": "compact compile src/[contract-name].compact src/managed/[contract-name] && deno task convert:js", + "convert:js": "deno run -A convert-js.ts src/managed/[contract-name]/contract/index.cjs src/managed/[contract-name]/contract/index.ts", + "contract:compile": "deno task compact" + }, + "imports": { + "vitest": "npm:vitest@^3.2.4" + } +} \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/_index.ts.rename b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/_index.ts.rename new file mode 100644 index 000000000..aa17373f6 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/_index.ts.rename @@ -0,0 +1,2 @@ +export * as [contract-code-name] from "./managed/[contract-name]/contract/index.ts"; +export * from "./witnesses.ts"; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/AccessControl.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/AccessControl.compact new file mode 100644 index 000000000..d6c577341 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/AccessControl.compact @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/AccessControl.compact) + +pragma language_version >= 0.18.0; + +/** + * @module AccessControl + * @description An unshielded AccessControl library. + * This module provides a role-based access control mechanism, where roles can be used to + * represent a set of permissions. + * + * Roles are referred to by their `Bytes<32>` identifier. These should be exposed + * in the top-level contract and be unique. One way to achieve this is by + * using `export sealed ledger` hash digests that are initialized in the top-level contract: + * + * ```typescript + * import CompactStandardLibrary; + * import "./node_modules/@openzeppelin-compact/accessControl/src/AccessControl" prefix AccessControl_; + * + * export sealed ledger MY_ROLE: Bytes<32>; + * + * constructor() { + * MY_ROLE = persistentHash>(pad(32, "MY_ROLE")); + * } + * ``` + * + * To restrict access to a circuit, use {assertOnlyRole}: + * + * ```typescript + * circuit foo(): [] { + * assertOnlyRole(MY_ROLE); + * ... + * } + * ``` + * + * Roles can be granted and revoked dynamically via the {grantRole} and + * {revokeRole} circuits. Each role has an associated admin role, and only + * accounts that have a role's admin role can call {grantRole} and {revokeRole}. + * + * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means + * that only accounts with this role will be able to grant or revoke other + * roles. More complex role relationships can be created by using + * {_setRoleAdmin}. To set a custom `DEFAULT_ADMIN_ROLE`, implement the `Initializable` + * module and set `DEFAULT_ADMIN_ROLE` in the `initialize()` circuit. + * + * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to + * grant and revoke this role. Extra precautions should be taken to secure + * accounts that have been granted it. + * + * @notice Roles can only be granted to ZswapCoinPublicKeys + * through the main role approval circuits (`grantRole` and `_grantRole`). + * In other words, role approvals to contract addresses are disallowed through these + * circuits. + * This is because Compact currently does not support contract-to-contract calls which means + * if a contract is granted a role, the contract cannot directly call the protected + * circuit. + * + * @notice This module does offer an experimental circuit that allows roles to be granted + * to contract addresses (`_unsafeGrantRole`). + * Note that the circuit name is very explicit ("unsafe") with this experimental circuit. + * Until contract-to-contract calls are supported, + * there is no direct way for a contract to call protected circuits. + * + * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls + * are supported. + * + * @notice Missing Features and Improvements: + * + * - Role events + * - An ERC165-like interface + */ +module AccessControl { + import CompactStandardLibrary; + import "../utils/Utils" prefix Utils_; + + /** + * @description Mapping from a role identifier -> account -> its permissions. + * @type {Bytes<32>} roleId - A hash representing a role identifier. + * @type {Map, Boolean>} hasRole - A mapping from an account to a + * Boolean determining if the account is approved for a role. + * @type {Map} + * @type {Map, Map, Boolean>} _operatorRoles +  */ + export ledger _operatorRoles: Map, Map, Boolean>>; + + /** + * @description Mapping from a role identifier to an admin role identifier. + * @type {Bytes<32>} roleId - A hash representing a role identifier. + * @type {Bytes<32>} adminId - A hash representing an admin identifier. + * @type {Map} + * @type {Map, Bytes<32>>} _adminRoles +  */ + export ledger _adminRoles: Map, Bytes<32>>; + + export ledger DEFAULT_ADMIN_ROLE: Bytes<32>; + + /** + * @description Returns `true` if `account` has been granted `roleId`. + * + * @circuitInfo k=10, rows=487 + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - The account to query. + * @return {Boolean} - Whether the account has the specified role. +   */ + export circuit hasRole(roleId: Bytes<32>, account: Either): Boolean { + if ( + _operatorRoles.member(disclose(roleId)) && + _operatorRoles + .lookup(roleId) + .member(disclose(account)) + ) { + return _operatorRoles + .lookup(roleId) + .lookup(disclose(account)); + } else { + return false; + } + } + + /** + * @description Reverts if `ownPublicKey()` is missing `roleId`. + * + * @circuitInfo k=10, rows=345 + * + * Requirements: + * + * - The caller must have `roleId`. + * - The caller must not be a ContractAddress + * + * @param {Bytes<32>} roleId - The role identifier. + * @return {[]} - Empty tuple. + */ + export circuit assertOnlyRole(roleId: Bytes<32>): [] { + _checkRole(roleId, left(ownPublicKey())); + } + + /** + * @description Reverts if `account` is missing `roleId`. + * + * @circuitInfo k=10, rows=467 + * + * Requirements: + * + * - `account` must have `roleId`. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - The account to query. + * @return {[]} - Empty tuple. + */ + export circuit _checkRole(roleId: Bytes<32>, account: Either): [] { + assert(hasRole(roleId, account), "AccessControl: unauthorized account"); + } + + /** + * @description Returns the admin role that controls `roleId` or + * a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}. + * + * To change a role’s admin use {_setRoleAdmin}. + * + * @circuitInfo k=10, rows=207 + * + * @param {Bytes<32>} roleId - The role identifier. + * @return {Bytes<32>} roleAdmin - The admin role that controls `roleId`. + */ + export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { + if (_adminRoles.member(disclose(roleId))) { + return _adminRoles.lookup(disclose(roleId)); + } + return default>; + } + + /** + * @description Grants `roleId` to `account`. + * + * @circuitInfo k=10, rows=994 + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * - The caller must have `roleId`'s admin role. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit grantRole(roleId: Bytes<32>, account: Either): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _grantRole(roleId, account); + } + + /** + * @description Revokes `roleId` from `account`. + * + * @circuitInfo k=10, rows=827 + * + * Requirements: + * + * - The caller must have `roleId`'s admin role. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit revokeRole(roleId: Bytes<32>, account: Either): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _revokeRole(roleId, account); + } + + /** + * @description Revokes `roleId` from the calling account. + * + * @notice Roles are often managed via {grantRole} and {revokeRole}: this circuit's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * @circuitInfo k=10, rows=640 + * + * Requirements: + * + * - The caller must be `callerConfirmation`. + * - The caller must not be a `ContractAddress`. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} callerConfirmation - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either): [] { + assert(callerConfirmation == left(ownPublicKey()), "AccessControl: bad confirmation"); + + _revokeRole(roleId, callerConfirmation); + } + + /** + * @description Sets `adminRole` as `roleId`'s admin role. + * + * @circuitInfo k=10, rows=209 + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} adminRole - The admin role identifier. + * @return {[]} - Empty tuple. + */ + export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { + _adminRoles.insert(disclose(roleId), disclose(adminRole)); + } + + /** + * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + * Internal circuit without access restriction. + * + * @circuitInfo k=10, rows=734 + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted. + */ + export circuit _grantRole(roleId: Bytes<32>, account: Either): Boolean { + assert(!Utils_isContractAddress(account), "AccessControl: unsafe role approval"); + return _unsafeGrantRole(roleId, account); + } + + /** + * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + * Internal circuit without access restriction. It does NOT check if the role is granted to a ContractAddress. + * + * @circuitInfo k=10, rows=733 + * + * @notice External smart contracts cannot call the token contract at this time, so granting a role to an ContractAddress may + * render a circuit permanently inaccessible. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {Boolean} roleGranted - A boolean indicating if `role` was granted. + */ + export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either): Boolean { + if (hasRole(roleId, account)) { + return false; + } + + if (!_operatorRoles.member(disclose(roleId))) { + _operatorRoles.insert( + disclose(roleId), + default, + Boolean + >> + ); + _operatorRoles + .lookup(roleId) + .insert(disclose(account), true); + return true; + } + + _operatorRoles.lookup(roleId).insert(disclose(account), true); + return true; + } + + /** + * @description Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked. + * Internal circuit without access restriction. + * + * @circuitInfo k=10, rows=563 + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} adminRole - The admin role identifier. + * @return {Boolean} roleRevoked - A boolean indicating if `roleId` was revoked. + */ + export circuit _revokeRole(roleId: Bytes<32>, account: Either): Boolean { + if (!hasRole(roleId, account)) { + return false; + } + + _operatorRoles + .lookup(roleId) + .insert(disclose(account), false); + return true; + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/Ownable.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/Ownable.compact new file mode 100644 index 000000000..a5bb44b64 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/Ownable.compact @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/Ownable.compact) + +pragma language_version >= 0.18.0; + +/** + * @module Ownable + * @description An unshielded Ownable library. + * This modules provides a basic access control mechanism, where there is an owner + * that can be granted exclusive access to specific circuits. + * This approach is perfectly reasonable for contracts that have a single administrative user. + * + * The initial owner must be set by using the `initialize` circuit during construction. + * This can later be changed with `transferOwnership`. + * + * @notice Ownership can only be transferred to ZswapCoinPublicKeys + * through the main transfer circuits (`transferOwnership` and `_transferOwnership`). + * In other words, ownership transfers to contract addresses are disallowed through these + * circuits. + * This is because Compact currently does not support contract-to-contract calls which means + * if a contract is granted ownership, the owner contract cannot directly call the protected + * circuit. + * + * @notice This module does offer experimental circuits that allow ownership to be granted + * to contract addresses (`_unsafeTransferOwnership` and `_unsafeUncheckedTransferOwnership`). + * Note that the circuit names are very explicit ("unsafe") with these experimental circuits. + * Until contract-to-contract calls are supported, + * there is no direct way for a contract to call circuits of other contracts + * or transfer ownership back to a user. + * + * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls + * are supported. + */ +module Ownable { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + export ledger _owner: Either; + + /** + * @description Initializes the contract by setting the `initialOwner`. + * This must be called in the contract's constructor. + * + * @circuitInfo k=10, rows=258 + * + * Requirements: + * + * - Contract is not already initialized. + * - `initialOwner` is not a ContractAddress. + * - `initialOwner` is not the zero address. + * + * @param {Either} initialOwner - The initial owner of the contract. + * @returns {[]} Empty tuple. + */ + export circuit initialize(initialOwner: Either): [] { + Initializable_initialize(); + assert(!Utils_isKeyOrAddressZero(initialOwner), "Ownable: invalid initial owner"); + _transferOwnership(initialOwner); + } + + /** + * @description Returns the current contract owner. + * + * @circuitInfo k=10, rows=84 + * + * Requirements: + * + * - Contract is initialized. + * + * @returns {Either } - The contract owner. + */ + export circuit owner(): Either { + Initializable_assertInitialized(); + return _owner; + } + + /** + * @description Transfers ownership of the contract to `newOwner`. + * + * @circuitInfo k=10, rows=338 + * + * @notice Ownership transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. + * This restriction prevents permanently disabling access to a circuit. + * + * Requirements: + * + * - Contract is initialized. + * - The caller is the current contract owner. + * - `newOwner` is not a ContractAddress. + * - `newOwner` is not the zero address. + * + * @param {Either} newOwner - The new owner. + * @returns {[]} Empty tuple. + */ + export circuit transferOwnership(newOwner: Either): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(newOwner), "Ownable: unsafe ownership transfer"); + _unsafeTransferOwnership(newOwner); + } + + /** + * @description Unsafe variant of `transferOwnership`. + * + * @circuitInfo k=10, rows=335 + * + * @warning Ownership transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. + * Ownership privileges sent to a contract address may become uncallable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - The caller is the current contract owner. + * - `newOwner` is not the zero address. + * + * @param {Either} newOwner - The new owner. + * @returns {[]} Empty tuple. + */ + export circuit _unsafeTransferOwnership(newOwner: Either): [] { + Initializable_assertInitialized(); + assertOnlyOwner(); + assert(!Utils_isKeyOrAddressZero(newOwner), "Ownable: invalid new owner"); + _unsafeUncheckedTransferOwnership(newOwner); + } + + /** + * @description Leaves the contract without an owner. + * It will not be possible to call `assertOnlyOnwer` circuits anymore. + * Can only be called by the current owner. + * + * @circuitInfo k=10, rows=124 + * + * Requirements: + * + * - Contract is initialized. + * - The caller is the current contract owner. + * + * @returns {[]} Empty tuple. + */ + export circuit renounceOwnership(): [] { + Initializable_assertInitialized(); + assertOnlyOwner(); + _transferOwnership(burnAddress()); + } + + /** + * @description Throws if called by any account other than the owner. + * Use this to restrict access of specific circuits to the owner. + * + * @circuitInfo k=10, rows=115 + * + * Requirements: + * + * - Contract is initialized. + * - The caller is the current contract owner. + * + * @returns {[]} Empty tuple. + */ + export circuit assertOnlyOwner(): [] { + Initializable_assertInitialized(); + const caller = ownPublicKey(); + assert(caller == _owner.left, "Ownable: caller is not the owner"); + } + + /** + * @description Transfers ownership of the contract to `newOwner` without + * enforcing permission checks on the caller. + * + * @circuitInfo k=10, rows=219 + * + * @notice Ownership transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. + * This restriction prevents circuits from being inadvertently locked in contracts. + * + * Requirements: + * + * - Contract is initialized. + * - `newOwner` is not a ContractAddress. + * + * @param {Either} newOwner - The new owner. + * @returns {[]} Empty tuple. + */ + export circuit _transferOwnership(newOwner: Either): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(newOwner), "Ownable: unsafe ownership transfer"); + _unsafeUncheckedTransferOwnership(newOwner); + } + + /** + * @description Unsafe variant of `_transferOwnership`. + * + * @circuitInfo k=10, rows=216 + * + * @warning Ownership transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. + * Ownership privileges sent to a contract address may become uncallable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} newOwner - The new owner. + * @returns {[]} Empty tuple. + */ + export circuit _unsafeUncheckedTransferOwnership(newOwner: Either): [] { + Initializable_assertInitialized(); + _owner = disclose(newOwner); + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/ZOwnablePK.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/ZOwnablePK.compact new file mode 100644 index 000000000..588d70011 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/ZOwnablePK.compact @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/ZOwnablePK.compact) + +pragma language_version >= 0.18.0; + +/** + * @module ZOwnablePK + * @description A shielded, public key-derived Ownable module. + * + * `ZOwnablePK` provides a privacy-preserving access control mechanism + * for contracts with a single administrative user. Unlike traditional + * `Ownable` implementations that store or expose the owner's public key + * on-chain, this module stores only a commitment to a hashed identifier + * derived from the owner's public key and a secret nonce. + * For the strongest security guarantees, use an Air-Gapped Public Key. + * + * @notice This module explicitly supports commitments derived from public keys; + * however, it may be possible to use contract addresses when contract-to-contract + * calls become available. This will be revisited when it's known if/how witnesses + * are used from a contract address context. + * + * @dev Features: + * - Obfuscated owner identity: The owner's public key is never revealed on-chain. + * - Stateless verification: The contract never needs access to the full public key. + * - Built-in support for transfer and renounce functionality. + * - Instance-specific salts to prevent cross-contract correlation. + * - Deterministic hashing with `persistentHash` to support zero-knowledge verification. + * + * @dev Commitment structure: + * ``` + * id = SHA256(pk, secretNonce) + * commitment = SHA256(id, instanceSalt, counter, "ZOwnablePK:shield:") + * ``` + * The commitment changes on each transfer due to the incrementing `counter`, + * providing unlinkability across ownership changes. + * + * @dev Security Considerations: + * - The `secretNonce` must be kept private. Loss of the nonce prevents the + * owner from proving ownership or transferring it. + * - Ownership validation is entirely circuit-based using witness-provided values. + * - The `_instanceSalt` is immutable and used to differentiate deployments. + * + * @notice Best used for single-admin contracts with privacy requirements. + * It is not designed for multi-owner or role-based access control. + */ +module ZOwnablePK { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + + /** + * @ledger _ownerCommitment + * @description Stores the current hashed commitment representing the owner. + * This commitment is derived from the public identifier (e.g., `SHA256(pk, nonce)`), + * the `instanceSalt`, the transfer `counter`, and a domain separator. + * + * A commitment of `default>` (i.e., zero) indicates the contract is unowned. + */ + export ledger _ownerCommitment: Bytes<32>; + /** + * @ledger _counter + * @description Internal transfer counter used to prevent commitment reuse. + * + * Increments by 1 on every successful ownership transfer. Combined with `id` and + * `instanceSalt` to compute unique owner commitments over time. + */ + export ledger _counter: Counter; + /** + * @sealed @ledger _instanceSalt + * @description A per-instance value provided at initialization used to namespace + * commitments for this contract instance. + * + * This salt prevents commitment collisions across contracts that might otherwise use + * the same owner identifiers or domain parameters. It is immutable after initialization. + */ + export sealed ledger _instanceSalt: Bytes<32>; + + /** + * @witness wit_secretNonce + * @description A private per-user nonce used in deriving the shielded owner identifier. + * + * Combined with the user's public key as `SHA256(pk, nonce)` to produce an obfuscated, + * unlinkable identity commitment. Users are encouraged to rotate this value on ownership changes. + */ + export witness wit_secretNonce(): Bytes<32>; + + /** + * @description Initializes the contract by setting the initial owner via `ownerId` + * and storing the `instanceSalt` that acts as a privacy additive for preventing + * duplicate commitments among other contracts implementing ZOwnablePK. + * + * @warning The `ownerId` must be calculated prior to contract deployment using the SHA256 hashing algorithm. + * Using any other algorithm will result in a permanent loss of contract access. + * + * @circuitInfo k=14, rows=14933 + * + * Requirements: + * + * - Contract is not initialized. + * - `ownerId` is not zero. + * + * @param {Bytes<32>} ownerId - The owner's unique identifier SHA256(pk, nonce). + * @param {Bytes<32>} instanceSalt - Contract salt to prevent duplicate commitments if + * users reuse their PK and secretNonce witness (not recommended). + * @returns {[]} Empty tuple. + */ + export circuit initialize(ownerId: Bytes<32>, instanceSalt: Bytes<32>): [] { + Initializable_initialize(); + + assert(ownerId != default>, "ZOwnablePK: invalid id"); + _instanceSalt = disclose(instanceSalt); + _transferOwnership(ownerId); + } + + /** + * @description Returns the current commitment representing the contract owner. + * The full commitment is: `SHA256(SHA256(pk, nonce), instanceSalt, counter, domain)`. + * + * @circuitInfo k=10, rows=57 + * + * Requirements: + * + * - Contract is initialized. + * + * @returns {Bytes<32>} The current owner's commitment. + */ + export circuit owner(): Bytes<32> { + Initializable_assertInitialized(); + return _ownerCommitment; + } + + /** + * @description Transfers ownership to `newOwnerId`. + * `newOwnerId` must be precalculated and given to the current owner off chain. + * + * @circuitInfo k=16, rows=39240 + * + * Requirements: + * + * - Contract is initialized. + * - Caller is the the current owner. + * - `newOwnerId` is not an empty array. + * + * @param {Bytes<32>} newOwnerId - The new owner's unique identifier (`SHA256(pk, nonce)`). + * @returns {[]} Empty tuple. + */ + export circuit transferOwnership(newOwnerId: Bytes<32>): [] { + Initializable_assertInitialized(); + + assertOnlyOwner(); + assert(newOwnerId != default>, "ZOwnablePK: invalid id"); + _transferOwnership(newOwnerId); + } + + /** + * @description Leaves the contract without an owner. + * It will not be possible to call `assertOnlyOnwer` circuits anymore. + * Can only be called by the current owner. + * + * @circuitInfo k=15, rows=24442 + * + * Requirements: + * + * - Contract is initialized. + * - Caller is the the current owner. + * + * @returns {[]} Empty tuple. + */ + export circuit renounceOwnership(): [] { + Initializable_assertInitialized(); + + assertOnlyOwner(); + _ownerCommitment.resetToDefault(); + } + + /** + * @description Throws if called by any account whose id hash `SHA256(pk, nonce)` does not match + * the stored owner commitment. + * Use this to only allow the owner to call specific circuits. + * + * @circuitInfo k=15, rows=24437 + * + * Requirements: + * + * - Contract is initialized. + * - Caller's id (`SHA256(pk, nonce)`) when used in `_computeOwnerCommitment` equals + * the stored `_ownerCommitment`, thus verifying themselves as the owner. + * + * @returns {[]} Empty tuple. + */ + export circuit assertOnlyOwner(): [] { + Initializable_assertInitialized(); + + const nonce = wit_secretNonce(); + const callerAsEither = Either { + is_left: true, + left: ownPublicKey(), + right: ContractAddress { bytes: pad(32, "") } + }; + const id = _computeOwnerId(callerAsEither, nonce); + assert(_ownerCommitment == _computeOwnerCommitment(id, _counter), "ZOwnablePK: caller is not the owner"); + } + + /** + * @description Computes the owner commitment from the given `id` and `counter`. + * + * ## Owner ID (`id`) + * The `id` is expected to be computed off-chain as: + * `id = SHA256(pk, nonce)` + * + * - `pk`: The owner's public key. + * - `nonce`: A secret nonce scoped to the instance, ideally rotated with each transfer. + * + * ## Commitment Derivation + * `commitment = SHA256(id, instanceSalt, counter, domain)` + * + * - `id`: See above. + * - `instanceSalt`: A unique per-deployment salt, stored during initialization. + * This prevents commitment collisions across deployments. + * - `counter`: Incremented with each ownership transfer, ensuring uniqueness + * even with repeated `id` values. Cast to `Field` then `Bytes<32>` for hashing. + * - `domain`: Domain separator `"ZOwnablePK:shield:"` (padded to 32 bytes) to prevent + * hash collisions when extending the module or using similar commitment schemes. + * + * @circuitInfo k=14, rows=14853 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} id - The unique identifier of the owner calculated by `SHA256(pk, nonce)`. + * @param {Uint<64>} counter - The current counter or round. This increments by `1` + * after every transfer to prevent duplicate commitments given the same `id`. + * @returns {Bytes<32>} The commitment derived from `id` and `counter`. + */ + export circuit _computeOwnerCommitment( + id: Bytes<32>, + counter: Uint<64>, + ): Bytes<32> { + Initializable_assertInitialized(); + return persistentHash>>( + [ + id, + _instanceSalt, + counter as Field as Bytes<32>, + pad(32, "ZOwnablePK:shield:") + ] + ); + } + + /** + * @description Computes the unique identifier (`id`) of the owner from their + * public key and a secret nonce. + * + * ## ID Derivation + * `id = SHA256(pk, nonce)` + * + * - `pk`: The public key of the caller. This is passed explicitly to allow + * for off-chain derivation, testing, or scenarios where the caller is + * different from the subject of the computation. + * We recommend using an Air-Gapped Public Key. + * - `nonce`: A secret nonce tied to the identity. The generation strategy is + * left to the user, offering different security/convenience trade-offs. + * + * The result is a 32-byte commitment that uniquely identifies the owner. + * This value is later used in owner commitment hashing, + * and acts as a privacy-preserving alternative to a raw public key. + * + * @notice This module allows ownership to be tied to an identity commitment derived + * from a public key and secret nonce. + * While typically used with user public keys, this mechanism may also + * support contract addresses as identifiers in future contract-to-contract + * interactions. Both are treated as 32-byte values (`Bytes<32>`). + * + * Requirements: + * + * - `pk` is not a ContractAddress. + * + * @param {Either} pk - The public key of the identity being committed. + * @param {Bytes<32>} nonce - A private nonce to scope the commitment. + * @returns {Bytes<32>} The computed owner ID. + */ + export pure circuit _computeOwnerId( + pk: Either, + nonce: Bytes<32> + ): Bytes<32> { + assert(pk.is_left, "ZOwnablePK: contract address owners are not yet supported"); + + return persistentHash>>([pk.left.bytes, nonce]); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * + * @circuitInfo k=14, rows=14823 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} newOwnerId - The unique identifier of the new owner + * calculated by `SHA256(pk, nonce)`. + * @returns {[]} Empty tuple. + */ + export circuit _transferOwnership(newOwnerId: Bytes<32>): [] { + Initializable_assertInitialized(); + + _counter.increment(1); + _ownerCommitment = _computeOwnerCommitment(disclose(newOwnerId), _counter); + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/AccessControlWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/AccessControlWitnesses.ts new file mode 100644 index 000000000..454fb3805 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/AccessControlWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/witnesses/AccessControlWitnesses.ts) + +// This is how we type an empty object. +export type AccessControlPrivateState = Record; +export const AccessControlWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/OwnableWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/OwnableWitnesses.ts new file mode 100644 index 000000000..561b62b57 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/OwnableWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/witnesses/OwnableWitnesses.ts) + +// This is how we type an empty object. +export type OwnablePrivateState = Record; +export const OwnableWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/ZOwnablePKWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/ZOwnablePKWitnesses.ts new file mode 100644 index 000000000..62cc3bba9 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/access/witnesses/ZOwnablePKWitnesses.ts @@ -0,0 +1,68 @@ +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../artifacts/MockZOwnablePK/contract/index.cjs'; + +/** + * @description Interface defining the witness methods for ZOwnablePK operations. + * @template P - The private state type. + */ +export interface IZOwnablePKWitnesses

    { + /** + * Retrieves the secret nonce from the private state. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret nonce as a Uint8Array. + */ + wit_secretNonce(context: WitnessContext): [P, Uint8Array]; +} + +/** + * @description Represents the private state of an ownable contract, storing a secret nonce. + */ +export type ZOwnablePKPrivateState = { + /** @description A 32-byte secret nonce used as a privacy additive. */ + secretNonce: Buffer; +}; + +/** + * @description Utility object for managing the private state of an Ownable contract. + */ +export const ZOwnablePKPrivateState = { + /** + * @description Generates a new private state with a random secret nonce. + * @returns A fresh ZOwnablePKPrivateState instance. + */ + generate: (): ZOwnablePKPrivateState => { + return { secretNonce: getRandomValues(Buffer.alloc(32)) }; + }, + + /** + * @description Generates a new private state with a user-defined secret nonce. + * Useful for deterministic nonce generation or advanced use cases. + * + * @param nonce - The 32-byte secret nonce to use. + * @returns A fresh ZOwnablePKPrivateState instance with the provided nonce. + * + * @example + * ```typescript + * // For deterministic nonces (user-defined scheme) + * const deterministicNonce = myDeterministicScheme(...); + * const privateState = ZOwnablePKPrivateState.withNonce(deterministicNonce); + * ``` + */ + withNonce: (nonce: Buffer): ZOwnablePKPrivateState => { + return { secretNonce: nonce }; + }, +}; + +/** + * @description Factory function creating witness implementations for Ownable operations. + * @returns An object implementing the Witnesses interface for ZOwnablePKPrivateState. + */ +export const ZOwnablePKWitnesses = + (): IZOwnablePKWitnesses => ({ + wit_secretNonce( + context: WitnessContext, + ): [ZOwnablePKPrivateState, Uint8Array] { + return [context.privateState, context.privateState.secretNonce]; + }, + }); diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/ShieldedToken.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/ShieldedToken.compact new file mode 100644 index 000000000..16663c03b --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/ShieldedToken.compact @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (archive/ShieldedToken.compact) + +pragma language_version >= 0.18.0; + +/** + * @module ShieldedToken (archived until further notice, DO NOT USE IN PRODUCTION) + * @description A shielded token module. + * + * @notice This module utilizes the existing coin infrastructure of Midnight. + * Due to the current limitations of the network, this module should NOT be used. + * + * Some of the limitations include: + * + * - No custom spend logic. Once users receive tokens, there's no mechanism to + * enforce any token behaviors. This is a big issue with stable coins, for instance. + * Most stable coins want the ability to pause functionality and/or freeze assets from + * specific addresses. This is currently not possible. + * + * - Cannot guarantee proper total supply accounting. The total supply of a given token + * is stored in the contract state. There's nothing to prevent users from burning + * tokens manually by directly sending them to the burn address. This breaks the + * total supply accounting (and potentially many other mechanisms). + * + * @notice This module will be revisited when the Midnight network can offer solutions to these + * issues. Until then, the recommendation is to use unshielded tokens. + * + * @dev Future ideas to consider: + * + * - Provide a self-minting mechanism. + * - Enable the Shielded contract itself to transfer. + * - Should this be a part of the Shielded module itself or as an extension? + */ +module ShieldedToken { // DO NOT USE IN PRODUCTION! + import CompactStandardLibrary; + import "../utils/Utils" prefix Utils_; + + // Public state + export ledger _counter: Counter; + export ledger _nonce: Bytes<32>; + export ledger _totalSupply: Uint<64>; + export sealed ledger _domain: Bytes<32>; + export sealed ledger _name: Maybe>; + export sealed ledger _symbol: Maybe>; + export sealed ledger _decimals: Uint<8>; + + /** + * @description Initializes the contract by setting the initial nonce + * and the metadata. + * + * @return {[]} - Empty tuple. + */ + export circuit initializer( + initNonce: Bytes<32>, + name_: Maybe>, + symbol_: Maybe>, + decimals_ :Uint<8> + ): [] { + _nonce = disclose(initNonce); + _domain = pad(32, "ShieldedToken"); + _name = disclose(name_); + _symbol = disclose(symbol_); + _decimals = disclose(decimals_); + } + + /** + * @description Returns the token name. + * + * @return {Maybe>} - The token name. + */ + export circuit name(): Maybe> { + return _name; + } + + /** + * @description Returns the symbol of the token. + * + * @return {Maybe>} - The token symbol. + */ + export circuit symbol(): Maybe> { + return _symbol; + } + + /** + * @description Returns the number of decimals used to get its user representation. + * + * @return {Uint<8>} - The account's token balance. + */ + export circuit decimals(): Uint<8> { + return _decimals; + } + + /** + * @description Returns the value of tokens in existence. + * @notice The total supply accounting mechanism cannot be guaranteed to be accurate. + * There is nothing to prevent users from directly sending tokens to the burn + * address without going through the contract; thus, tokens will be burned + * but the accounted supply will not change. + * + * @return {Uint<64>} - The total supply of tokens. + */ + export circuit totalSupply(): Uint<64> { + return _totalSupply; + } + + /** + * @description Mints `amount` of tokens to `recipient`. + * @dev This circuit does not include access control meaning anyone can call it. + * + * @param {recipient} - The ZswapCoinPublicKey or ContractAddress that receives the minted tokens. + * @param {amount} - The value of tokens minted. + * @return {CoinInfo} - The description of the newly created coin. + */ + export circuit mint(recipient: Either, amount: Uint<64>): CoinInfo { + assert(!Utils_isKeyOrAddressZero(recipient), "ShieldedToken: invalid recipient"); + + _counter.increment(1); + const newNonce = evolveNonce(_counter, _nonce); + _nonce = newNonce; + const ret = mintToken(_domain, disclose(amount), _nonce, disclose(recipient)); + _totalSupply = disclose(_totalSupply + amount as Uint<64>); + return ret; + } + + /** + * @description Destroys `amount` of `coin` by sending it to the burn address. + * @dev This circuit does not include access control meaning anyone can call it. + * @throws Will throw if `coin` color is not this contract's token type. + * @throws Will throw if `amount` is less than `coin` value. + * + * @param {coin} - The coin description that will be burned. + * @param {amount} - The value of `coin` that will be burned. + * @return {SendResult} - The output of sending tokens to the burn address. This may include change from + * spending the output if available. + */ + export circuit burn(coin: CoinInfo, amount: Uint<64>): SendResult { + assert(coin.color == tokenType(_domain, kernel.self()), "ShieldedToken: token not created from this contract"); + assert(coin.value >= amount, "ShieldedToken: insufficient token amount to burn"); + + receive(disclose(coin)); + _totalSupply = disclose(_totalSupply - amount); + + const sendRes = sendImmediate(disclose(coin), burnAddress(), disclose(amount)); + if (disclose(sendRes.change.is_some)) { + // tmp for only zswap because we should be able to handle contracts burning tokens + // and returning change. + const tmpAddr = left(ownPublicKey()); + sendImmediate(disclose(sendRes.change.value), tmpAddr, disclose(sendRes.change.value.value)); + } + + return sendRes; + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/witnesses/ShieldedTokenWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/witnesses/ShieldedTokenWitnesses.ts new file mode 100644 index 000000000..82437f0e2 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/archive/witnesses/ShieldedTokenWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (archive/witnesses/ShieldedTokenWitnesses.ts) + +// This is how we type an empty object. +export type ShieldedTokenPrivateState = Record; +export const ShieldedTokenWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Initializable.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Initializable.compact new file mode 100644 index 000000000..a18551803 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Initializable.compact @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (security/Initializable.compact) + +pragma language_version >= 0.18.0; + +/** + * @module Initializable + * @description Initializable provides a simple mechanism that mimics the functionality of a constructor. + */ +module Initializable { + import CompactStandardLibrary; + + export ledger _isInitialized: Boolean; + + /** + * @description Initializes the state thus ensuring the calling circuit can only be called once. + * + * @circuitInfo k=10, rows=38 + * + * Requirements: + * + * - Contract must not be initialized. + * + * @return {[]} - Empty tuple. + */ + export circuit initialize(): [] { + assertNotInitialized(); + _isInitialized = true; + } + + /** + * @description Asserts that the contract has been initialized, throwing an error if not. + * + * @circuitInfo k=10, rows=31 + * + * Requirements: + * + * - Contract must be initialized. + * + * @return {[]} - Empty tuple. + */ + export circuit assertInitialized(): [] { + assert(_isInitialized, "Initializable: contract not initialized"); + } + + /** + * @description Asserts that the contract has not been initialized, throwing an error if it has. + * + * @circuitInfo k=10, rows=35 + * + * Requirements: + * + * - Contract must not be initialized. + * + * @return {[]} - Empty tuple. + */ + export circuit assertNotInitialized(): [] { + assert(!_isInitialized, "Initializable: contract already initialized"); + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Pausable.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Pausable.compact new file mode 100644 index 000000000..cc474e525 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/Pausable.compact @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (security/Pausable.compact) + +pragma language_version >= 0.18.0; + +/** + * @module Pausable + * @description Pausable allows the implementing contract to implement + * an emergency stop mechanism. Only circuits that call `assertPaused` + * or `assertNotPaused` will be affected by this mechanism. + */ +module Pausable { + import CompactStandardLibrary; + + export ledger _isPaused: Boolean; + + /** + * @description Returns true if the contract is paused, and false otherwise. + * + * @circuitInfo k=10, rows=32 + * + * @return {Boolean} True if paused. + */ + export circuit isPaused(): Boolean { + return _isPaused; + } + + /** + * @description Makes a circuit only callable when the contract is paused. + * + * @circuitInfo k=10, rows=31 + * + * Requirements: + * + * - Contract must be paused. + * + * @return {[]} - Empty tuple. + */ + export circuit assertPaused(): [] { + assert(_isPaused, "Pausable: not paused"); + } + + /** + * @description Makes a circuit only callable when the contract is not paused. + * + * @circuitInfo k=10, rows=35 + * + * Requirements: + * + * - Contract must not be paused. + * + * @return {[]} - Empty tuple. + */ + export circuit assertNotPaused(): [] { + assert(!_isPaused, "Pausable: paused"); + } + + /** + * @description Triggers a stopped state. + * + * @circuitInfo k=10, rows=38 + * + * Requirements: + * + * - Contract must not be paused. + * + * @return {[]} - Empty tuple. + */ + export circuit _pause(): [] { + assertNotPaused(); + _isPaused = true; + } + + /** + * @description Lifts the pause on the contract. + * + * @circuitInfo k=10, rows=34 + * + * Requirements: + * + * - Contract must be paused. + * + * @return {[]} - Empty tuple. + */ + export circuit _unpause(): [] { + assertPaused(); + _isPaused = false; + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/InitializableWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/InitializableWitnesses.ts new file mode 100644 index 000000000..37d8f58ca --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/InitializableWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (security/witnesses/InitializableWitnesses.ts) + +// This is how we type an empty object. +export type InitializablePrivateState = Record; +export const InitializableWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/PausableWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/PausableWitnesses.ts new file mode 100644 index 000000000..dc36dfacb --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/security/witnesses/PausableWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (security/witnesses/PausableWitnesses.ts) + +// This is how we type an empty object. +export type PausablePrivateState = Record; +export const PausableWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/FungibleToken.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/FungibleToken.compact new file mode 100644 index 000000000..4623083a3 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/FungibleToken.compact @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/FungibleToken.compact) + +pragma language_version >= 0.18.0; + +/** + * @module FungibleToken + * @description An unshielded FungibleToken library. + * + * @notice One notable difference regarding this implementation and the EIP20 spec + * consists of the token size. Uint<128> is used as the token size because Uint<256> + * cannot be supported. + * This is due to encoding limits on the midnight circuit backend: + * https://github.com/midnightntwrk/compactc/issues/929 + * + * @notice At the moment Midnight does not support contract-to-contract communication, but + * there are ongoing efforts to enable this in the future. Thus, the main circuits of this module + * restrict developers from sending tokens to contracts; however, we provide developers + * the ability to experiment with sending tokens to contracts using the `_unsafe` + * transfer methods. Once contract-to-contract communication is available we will follow the + * deprecation plan outlined below: + * + * Initial Minor Version Change: + * + * - Mark _unsafeFN as deprecated and emit a warning if possible. + * - Keep its implementation intact so existing callers continue to work. + * + * Later Major Version Change: + * + * - Drop _unsafeFN and remove `isContract` guard from `FN`. + * - By this point, anyone using _unsafeFN should have migrated to the now C2C-capable `FN`. + * + * Due to the vast incompatibilities with the EIP20 spec, it is our + * opinion that this implementation should not be called ERC20 at this time + * as this would be both very confusing and misleading. This may change as more + * features become available. The list of missing features is as follows: + * + * - Full uint256 support. + * - Events. + * - Contract-to-contract calls. + */ +module FungibleToken { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + /** + * @description Mapping from account addresses to their token balances. + * @type {Either} account - The account address. + * @type {Uint<128>} balance - The balance of the account. + * @type {Map} + * @type {Map, Uint<128>>} _balances + */ + export ledger _balances: Map, Uint<128>>; + /** + * @description Mapping from owner accounts to spender accounts and their allowances. + * @type {Either} account - The owner account address. + * @type {Either} spender - The spender account address. + * @type {Uint<128>} allowance - The amount allowed to be spent by the spender. + * @type {Map>} + * @type {Map, Map, Uint<128>>>} _allowances + */ + export ledger _allowances: Map, Map, Uint<128>>>; + + export ledger _totalSupply: Uint<128>; + + export sealed ledger _name: Opaque<"string">; + export sealed ledger _symbol: Opaque<"string">; + export sealed ledger _decimals: Uint<8>; + + /** + * @description Initializes the contract by setting the name, symbol, and decimals. + * @dev This MUST be called in the implementing contract's constructor. Failure to do so + * can lead to an irreparable contract. + * + * @circuitInfo k=10, rows=71 + * + * @param {Opaque<"string">} name_ - The name of the token. + * @param {Opaque<"string">} symbol_ - The symbol of the token. + * @param {Uint<8>} decimals_ - The number of decimals used to get the user representation. + * @return {[]} - Empty tuple. + */ + export circuit initialize( + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_:Uint<8> + ): [] { + Initializable_initialize(); + _name = disclose(name_); + _symbol = disclose(symbol_); + _decimals = disclose(decimals_); + } + + /** + * @description Returns the token name. + * + * @circuitInfo k=10, rows=37 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The token name. + */ + export circuit name(): Opaque<"string"> { + Initializable_assertInitialized(); + return _name; + } + + /** + * @description Returns the symbol of the token. + * + * @circuitInfo k=10, rows=37 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The token name. + */ + export circuit symbol(): Opaque<"string"> { + Initializable_assertInitialized(); + return _symbol; + } + + /** + * @description Returns the number of decimals used to get its user representation. + * + * @circuitInfo k=10, rows=36 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<8>} - The account's token balance. + */ + export circuit decimals(): Uint<8> { + Initializable_assertInitialized(); + return _decimals; + } + + /** + * @description Returns the value of tokens in existence. + * + * @circuitInfo k=10, rows=36 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<128>} - The total supply of tokens. + */ + export circuit totalSupply(): Uint<128> { + Initializable_assertInitialized(); + return _totalSupply; + } + + /** + * @description Returns the value of tokens owned by `account`. + * + * @circuitInfo k=10, rows=310 + * + * @dev Manually checks if `account` is a key in the map and returns 0 if it is not. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} account - The public key or contract address to query. + * @return {Uint<128>} - The account's token balance. + */ + export circuit balanceOf(account: Either): Uint<128> { + Initializable_assertInitialized(); + if (!_balances.member(disclose(account))) { + return 0; + } + + return _balances.lookup(disclose(account)); + } + + /** + * @description Moves a `value` amount of tokens from the caller's account to `to`. + * + * @circuitInfo k=11, rows=1173 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not a ContractAddress. + * - `to` is not the zero address. + * - The caller has a balance of at least `value`. + * + * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Uint<128>} value - The amount to transfer. + * @return {Boolean} - As per the IERC20 spec, this MUST return true. + */ + export circuit transfer(to: Either, value: Uint<128>): Boolean { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + return _unsafeTransfer(to, value); + } + + /** + * @description Unsafe variant of `transfer` which allows transfers to contract addresses. + * + * @circuitInfo k=11, rows=1170 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not the zero address. + * - The caller has a balance of at least `value`. + * + * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Uint<128>} value - The amount to transfer. + * @return {Boolean} - As per the IERC20 spec, this MUST return true. + */ + export circuit _unsafeTransfer(to: Either, value: Uint<128>): Boolean { + Initializable_assertInitialized(); + const owner = left(ownPublicKey()); + _unsafeUncheckedTransfer(owner, to, value); + return true; + } + + /** + * @description Returns the remaining number of tokens that `spender` will be allowed to spend on behalf of `owner` + * through `transferFrom`. This value changes when `approve` or `transferFrom` are called. + * + * @circuitInfo k=10, rows=624 + * + * @dev Manually checks if `owner` and `spender` are keys in the map and returns 0 if they are not. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} owner - The public key or contract address of approver. + * @param {Either} spender - The public key or contract address of spender. + * @return {Uint<128>} - The `spender`'s allowance over `owner`'s tokens. + */ + export circuit allowance( + owner: Either, + spender: Either + ): Uint<128> { + Initializable_assertInitialized(); + if (!_allowances.member(disclose(owner)) || !_allowances.lookup(owner).member(disclose(spender))) { + return 0; + } + + return _allowances.lookup(owner).lookup(disclose(spender)); + } + + /** + * @description Sets a `value` amount of tokens as allowance of `spender` over the caller's tokens. + * + * @circuitInfo k=10, rows=452 + * + * Requirements: + * + * - Contract is initialized. + * - `spender` is not the zero address. + * + * @param {Either} spender - The Zswap key or ContractAddress that may spend on behalf of the caller. + * @param {Uint<128>} value - The amount of tokens the `spender` may spend. + * @return {Boolean} - Returns a boolean value indicating whether the operation succeeded. + */ + export circuit approve(spender: Either, value: Uint<128>): Boolean { + Initializable_assertInitialized(); + + const owner = left(ownPublicKey()); + _approve(owner, spender, value); + return true; + } + + /** + * @description Moves `value` tokens from `from` to `to` using the allowance mechanism. + * `value` is the deducted from the caller's allowance. + * + * @circuitInfo k=11, rows=1821 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not the zero address. + * - `from` must have a balance of at least `value`. + * - `to` is not the zero address. + * - `to` is not a ContractAddress. + * - The caller has an allowance of `from`'s tokens of at least `value`. + * + * @param {Either} from - The current owner of the tokens for the transfer, either a user or a contract. + * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Uint<128>} value - The amount to transfer. + * @return {Boolean} - As per the IERC20 spec, this MUST return true. + */ + export circuit transferFrom( + from: Either, + to: Either, + value: Uint<128> + ): Boolean { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + return _unsafeTransferFrom(from, to, value); + } + + /** + * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. + * + * @circuitInfo k=11, rows=1818 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not the zero address. + * - `from` must have a balance of at least `value`. + * - `to` is not the zero address. + * - The caller has an allowance of `from`'s tokens of at least `value`. + * + * @param {Either} from - The current owner of the tokens for the transfer, either a user or a contract. + * @param {Either} to - The recipient of the transfer, either a user or a contract. + * @param {Uint<128>} value - The amount to transfer. + * @return {Boolean} - As per the IERC20 spec, this MUST return true. + */ + export circuit _unsafeTransferFrom( + from: Either, + to: Either, + value: Uint<128> + ): Boolean { + Initializable_assertInitialized(); + + const spender = left(ownPublicKey()); + _spendAllowance(from, spender, value); + _unsafeUncheckedTransfer(from, to, value); + return true; + } + + /** + * @description Moves a `value` amount of tokens from `from` to `to`. + * This circuit is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * @circuitInfo k=11, rows=1312 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not be the zero address. + * - `from` must have at least a balance of `value`. + * - `to` must not be the zero address. + * - `to` must not be a ContractAddress. + * + * @param {Either} from - The owner of the tokens to transfer. + * @param {Either} to - The receipient of the transferred tokens. + * @param {Uint<128>} value - The amount of tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit _transfer( + from: Either, + to: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "FungibleToken: Unsafe Transfer"); + _unsafeUncheckedTransfer(from, to, value); + } + + /** + * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. + * + * @circuitInfo k=11, rows=1309 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not the zero address. + * - `to` is not the zero address. + * + * @param {Either} from - The owner of the tokens to transfer. + * @param {Either} to - The receipient of the transferred tokens. + * @param {Uint<128>} value - The amount of tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit _unsafeUncheckedTransfer( + from: Either, + to: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(from), "FungibleToken: invalid sender"); + assert(!Utils_isKeyOrAddressZero(to), "FungibleToken: invalid receiver"); + + _update(from, to, value); + } + + /** + * @description Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` + * (or `to`) is the zero address. + * @dev Checks for a mint overflow in order to output a more readable error message. + * + * @circuitInfo k=11, rows=1305 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} from - The original owner of the tokens moved (which is 0 if tokens are minted). + * @param {Either} to - The recipient of the tokens moved (which is 0 if tokens are burned). + * @param {Uint<128>} value - The amount of tokens moved from `from` to `to`. + * @return {[]} - Empty tuple. + */ + circuit _update( + from: Either, + to: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + if (Utils_isKeyOrAddressZero(disclose(from))) { + // Mint + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - _totalSupply >= value, "FungibleToken: arithmetic overflow"); + + _totalSupply = disclose(_totalSupply + value as Uint<128>); + } else { + const fromBal = balanceOf(from); + assert(fromBal >= value, "FungibleToken: insufficient balance"); + _balances.insert(disclose(from), disclose(fromBal - value as Uint<128>)); + } + + if (Utils_isKeyOrAddressZero(disclose(to))) { + // Burn + _totalSupply = disclose(_totalSupply - value as Uint<128>); + } else { + const toBal = balanceOf(to); + _balances.insert(disclose(to), disclose(toBal + value as Uint<128>)); + } + } + + /** + * @description Creates a `value` amount of tokens and assigns them to `account`, + * by transferring it from the zero address. Relies on the `update` mechanism. + * + * @circuitInfo k=10, rows=752 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not a ContractAddress. + * - `account` is not the zero address. + * + * @param {Either} account - The recipient of tokens minted. + * @param {Uint<128>} value - The amount of tokens minted. + * @return {[]} - Empty tuple. + */ + export circuit _mint( + account: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(account), "FungibleToken: Unsafe Transfer"); + _unsafeMint(account, value); + } + + /** + * @description Unsafe variant of `_mint` which allows transfers to contract addresses. + * + * @circuitInfo k=10, rows=749 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `account` is not the zero address. + * + * @param {Either} account - The recipient of tokens minted. + * @param {Uint<128>} value - The amount of tokens minted. + * @return {[]} - Empty tuple. + */ + export circuit _unsafeMint( + account: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(account), "FungibleToken: invalid receiver"); + _update(burnAddress(), account, value); + } + + /** + * @description Destroys a `value` amount of tokens from `account`, lowering the total supply. + * Relies on the `_update` mechanism. + * + * @circuitInfo k=10, rows=773 + * + * Requirements: + * + * - Contract is initialized. + * - `account` is not the zero address. + * - `account` must have at least a balance of `value`. + * + * @param {Either} account - The target owner of tokens to burn. + * @param {Uint<128>} value - The amount of tokens to burn. + * @return {[]} - Empty tuple. + */ + export circuit _burn( + account: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(account), "FungibleToken: invalid sender"); + _update(account, burnAddress(), value); + } + + /** + * @description Sets `value` as the allowance of `spender` over the `owner`'s tokens. + * This circuit is equivalent to `approve`, and can be used to + * + * @circuitInfo k=10, rows=583 + * + * e.g. set automatic allowances for certain subsystems, etc. + * + * Requirements: + * + * - Contract is initialized. + * - `owner` is not the zero address. + * - `spender` is not the zero address. + * + * @param {Either} owner - The owner of the tokens. + * @param {Either} spender - The spender of the tokens. + * @param {Uint<128>} value - The amount of tokens `spender` may spend on behalf of `owner`. + * @return {[]} - Empty tuple. + */ + export circuit _approve( + owner: Either, + spender: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(owner), "FungibleToken: invalid owner"); + assert(!Utils_isKeyOrAddressZero(spender), "FungibleToken: invalid spender"); + if (!_allowances.member(disclose(owner))) { + // If owner doesn't exist, create and insert a new sub-map directly + _allowances.insert(disclose(owner), default, Uint<128>>>); + } + _allowances.lookup(owner).insert(disclose(spender), disclose(value)); + } + + /** + * @description Updates `owner`'s allowance for `spender` based on spent `value`. + * Does not update the allowance value in case of infinite allowance. + * + * @circuitInfo k=10, rows=931 + * + * Requirements: + * + * - Contract is initialized. + * - `spender` must have at least an allowance of `value` from `owner`. + * + * @param {Either} owner - The owner of the tokens. + * @param {Either} spender - The spender of the tokens. + * @param {Uint<128>} value - The amount of token allowance to spend. + * @return {[]} - Empty tuple. + */ + export circuit _spendAllowance( + owner: Either, + spender: Either, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert((_allowances.member(disclose(owner)) && _allowances.lookup(owner).member(disclose(spender))), "FungibleToken: insufficient allowance"); + + const currentAllowance = _allowances.lookup(owner).lookup(disclose(spender)); + const MAX_UINT128 = 340282366920938463463374607431768211455; + if (currentAllowance < MAX_UINT128) { + assert(currentAllowance >= value, "FungibleToken: insufficient allowance"); + _approve(owner, spender, currentAllowance - value as Uint<128>); + } + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/MultiToken.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/MultiToken.compact new file mode 100644 index 000000000..b219059e2 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/MultiToken.compact @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/MultiToken.compact) + +pragma language_version >= 0.18.0; + +/** + * @module MultiToken + * @description An unshielded MultiToken library. This library is inspired by + * ERC1155. Many aspects of the EIP-1155 spec cannot be implemented in Compact; + * therefore, the MultiToken module should be treated as an approximation of + * the ERC1155 standard and not necessarily a compliant implementation. + * + * @notice One notable difference regarding this implementation and the EIP1155 spec + * consists of the token size. Uint<128> is used as the token size because Uint<256> + * cannot be supported. This is true for both token IDs and for amounts. + * This is due to encoding limits on the midnight circuit backend: + * https://github.com/midnightntwrk/compactc/issues/929 + * + * @notice Some features defined in th EIP1155 spec are NOT included. + * Such features include: + * + * 1. Batch mint, burn, transfer - Without support for dynamic arrays, + * batching transfers is difficult to do without a hacky solution. + * For instance, we could change the `to` and `from` parameters to be + * vectors. This would change the signature and would be both difficult + * to use and easy to misuse. + * + * 2. Querying batched balances - This can be somewhat supported. + * The issue, without dynamic arrays, is that the module circuit must use + * Vector for accounts and ids; therefore, the implementing contract must + * explicitly define the number of balances to query in the circuit i.e. + * + * balanceOfBatch_10( + * accounts: Vector<10, Either>, + * ids: Vector<10, Uint<128>> + * ): Vector<10, Uint<128>> + * + * Since this module does not offer mint or transfer batching, + * balance batching is also not included at this time. + * + * 3. Introspection - Compact currently cannot support contract-to-contract + * queries for introspection. ERC165 (or an equivalent thereof) is NOT + * included in the contract. + * + * 4. Safe transfers - The lack of an introspection mechanism means + * safe transfers of any kind can not be supported. + * BE AWARE: Tokens sent to a contract address MAY be lost forever. + * + * Due to the vast incompatibilities with the EIP1155 spec, it is our + * opinion that this implementation should not be called ERC1155 at this time + * as this would be both very confusing and misleading. This may change as more + * features become available. The list of missing features is as follows: + * + * - Full uint256 support. + * - Events. + * - Dynamic arrays. + * - Introspection. + * - Contract-to-contract calls for acceptance callback. + * + * @notice Further discussion and consideration required: + * + * - Consider changing the underscore in the internal methods to `unsafe` or + * adopting dot notation for prefixing imports. + * - Revise logic once contract-to-contract interactions are available on midnight. + */ +module MultiToken { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + /** + * @description Mapping from token ID to account balances. + * @type {Uint<128>} id - The token identifier. + * @type {Either} account - The account address. + * @type {Uint<128>} balance - The balance of the account for the token. + * @type {Map>} + * @type {Map, Map, Uint<128>>>} _balances + */ + export ledger _balances: Map, Map, Uint<128>>>; + + /** + * @description Mapping from account to operator approvals. + * @type {Either} account - The account address. + * @type {Either} operator - The operator address. + * @type {Boolean} approved - The approval status of the operator for the account. + * @type {Map>} + * @type {Map, Map, Boolean>>} + */ + export ledger _operatorApprovals: Map, Map, Boolean>>; + + /** + * @description Base URI for computing token URIs. + * Used as the URI for all token types by relying on ID substitution, e.g. https://token-cdn-domain/{id}.json + * @type {Opaque<"string">} _uri - The base URI for all token URIs. + */ + export ledger _uri: Opaque<"string">; + + /** + * @description Initializes the contract by setting the base URI for all tokens. + * + * @circuitInfo k=10, rows=45 + * + * Requirements: + * + * - Must be called in the contract's constructor. + * + * @param {Opaque<"string">} uri_ - The base URI for all token URIs. + * @return {[]} - Empty tuple. + */ + export circuit initialize(uri_: Opaque<"string">): [] { + Initializable_initialize(); + _setURI(uri_); + } + + /** + * @description This implementation returns the same URI for *all* token types. It relies + * on the token type ID substitution mechanism defined in the EIP: + * https://eips.ethereum.org/EIPS/eip-1155#metadata. + * Clients calling this function must replace the `\{id\}` substring with the + * actual token type ID. + * + * @circuitInfo k=10, rows=90 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Uint<128>} id - The token identifier to query. + * return {Opaque<"string">} - The base URI for all tokens. + */ + export circuit uri(id: Uint<128>): Opaque<"string"> { + Initializable_assertInitialized(); + + return _uri; + } + + /** + * @description Returns the amount of `id` tokens owned by `account`. + * + * @circuitInfo k=10, rows=439 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} account - The account balance to query. + * @param {Uint<128>} id - The token identifier to query. + * return {Uint<128>} - The quantity of `id` tokens that `account` owns. + */ + export circuit balanceOf(account: Either, id: Uint<128>): Uint<128> { + Initializable_assertInitialized(); + + if (!_balances.member(disclose(id)) || !_balances.lookup(id).member(disclose(account))) { + return 0; + } + return _balances.lookup(id).lookup(disclose(account)); + } + + /** + * @description Enables or disables approval for `operator` to manage all of the caller's assets. + * + * @circuitInfo k=10, rows=404 + * + * Requirements: + * + * - Contract is initialized. + * - `operator` is not the zero address. + * + * @param {Either} operator - The ZswapCoinPublicKey or ContractAddress + * whose approval is set for the caller's assets. + * @param {Boolean} approved - The boolean value determining if the operator may or may not handle the + * caller's assets. + * @return {[]} - Empty tuple. + */ + export circuit setApprovalForAll(operator: Either, approved: Boolean): [] { + Initializable_assertInitialized(); + + // TODO: Contract-to-contract calls not yet supported. + const caller = left(ownPublicKey()); + _setApprovalForAll(caller, operator, approved); + } + + /** + * @description Queries if `operator` is an authorized operator for `owner`. + * + * @circuitInfo k=10, rows=619 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Either} account - The queried possessor of assets. + * @param {Either} operator - The queried handler of `account`'s assets. + * @return {Boolean} - Whether or not `operator` has permission to handle `account`'s assets. + */ + export circuit isApprovedForAll( + account: Either, + operator: Either + ): Boolean { + Initializable_assertInitialized(); + + if (!_operatorApprovals.member(disclose(account)) || !_operatorApprovals.lookup(account).member(disclose(operator))) { + return false; + } + + return _operatorApprovals.lookup(account).lookup(disclose(operator)); + } + + /** + * @description Transfers ownership of `value` amount of `id` tokens from `from` to `to`. + * The caller must be `from` or approved to transfer on their behalf. + * + * @circuitInfo k=11, rows=1882 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * @extensibility External circuit. Can be used directly by consumers of this module. + * See **Extensibility** documentation for usage patterns. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not a ContractAddress. + * - `to` is not the zero address. + * - `from` is not the zero address. + * - Caller must be `from` or approved via `setApprovalForAll`. + * - `from` must have an `id` balance of at least `value`. + * + * @param {Either} from - The owner from which the transfer originates. + * @param {Either} to - The recipient of the transferred assets. + * @param {Uint<128>} id - The unique identifier of the asset type. + * @param {Uint<128>} value - The quantity of `id` tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit transferFrom( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> + ): [] { + assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + _unsafeTransferFrom(from, to, id, value); + } + + /** + * @description Transfers ownership of `value` amount of `id` tokens from `from` to `to`. + * Does not impose restrictions on the caller, making it suitable for composition + * in higher-level contract logic. + * + * @circuitInfo k=11, rows=1487 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not a ContractAddress. + * - `to` is not the zero address. + * - `from` is not the zero address. + * - `from` must have an `id` balance of at least `value`. + * + * @param {Either} from - The owner from which the transfer originates. + * @param {Either} to - The recipient of the transferred assets. + * @param {Uint<128>} id - The unique identifier of the asset type. + * @param {Uint<128>} value - The quantity of `id` tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit _transfer( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> + ): [] { + assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + _unsafeTransfer(from, to, id, value); + } + + /** + * @description Transfers a value amount of tokens of type id from from to to. + * This circuit will mint (or burn) if `from` (or `to`) is the zero address. + * + * @circuitInfo k=11, rows=1482 + * + * Requirements: + * + * - Contract is initialized. + * - If `from` is not zero, the balance of `id` of `from` must be >= `value`. + * + * @param {Either} from - The origin of the transfer. + * @param {Either} to - The destination of the transfer. + * @param {Uint<128>} id - The unique identifier of the asset type. + * @param {Uint<128>} value - The quantity of `id` tokens to transfer. + * @return {[]} - Empty tuple. + */ + circuit _update( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + + if (!Utils_isKeyOrAddressZero(disclose(from))) { + const fromBalance = balanceOf(from, id); + assert(fromBalance >= value, "MultiToken: insufficient balance"); + // overflow not possible + const newBalance = fromBalance - value; + _balances.lookup(id).insert(disclose(from), disclose(newBalance)); + } + + if (!Utils_isKeyOrAddressZero(disclose(to))) { + // id not initialized + if (!_balances.member(disclose(id))) { + _balances.insert(disclose(id), default, Uint<128>>>); + _balances.lookup(id).insert(disclose(to), disclose(value as Uint<128>)); + } else { + const toBalance = balanceOf(to, id), MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - toBalance >= value, "MultiToken: arithmetic overflow"); + _balances.lookup(id).insert(disclose(to), disclose(toBalance + value as Uint<128>)); + } + } + } + + /** + * @description Unsafe variant of `transferFrom` which allows transfers to contract addresses. + * The caller must be `from` or approved to transfer on their behalf. + * + * @circuitInfo k=11, rows=1881 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not the zero address. + * - `from` is not the zero address. + * - Caller must be `from` or approved via `setApprovalForAll`. + * - `from` must have an `id` balance of at least `value`. + * + * @param {Either} from - The owner from which the transfer originates. + * @param {Either} to - The recipient of the transferred assets. + * @param {Uint<128>} id - The unique identifier of the asset type. + * @param {Uint<128>} value - The quantity of `id` tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit _unsafeTransferFrom( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + + // TODO: Contract-to-contract calls not yet supported. + // Once available, handle ContractAddress recipients here. + const caller = left(ownPublicKey()); + if (disclose(from) != caller) { + assert(isApprovedForAll(from, caller), "MultiToken: unauthorized operator"); + } + + _unsafeTransfer(from, to, id, value); + } + + /** + * @description Unsafe variant of `_transfer` which allows transfers to contract addresses. + * Does not impose restrictions on the caller, making it suitable as a low-level + * building block for advanced contract logic. + * + * @circuitInfo k=11, rows=1486 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not the zero address. + * - `to` is not the zero address. + * - `from` must have an `id` balance of at least `value`. + * + * @param {Either} from - The owner from which the transfer originates. + * @param {Either} to - The recipient of the transferred assets. + * @param {Uint<128>} id - The unique identifier of the asset type. + * @param {Uint<128>} value - The quantity of `id` tokens to transfer. + * @return {[]} - Empty tuple. + */ + export circuit _unsafeTransfer( + from: Either, + to: Either, + id: Uint<128>, + value: Uint<128> + ): [] { + Initializable_assertInitialized(); + + assert(!Utils_isKeyOrAddressZero(from), "MultiToken: invalid sender"); + assert(!Utils_isKeyOrAddressZero(to), "MultiToken: invalid receiver"); + _update(from, to, id, value); + } + + /** + * @description Sets a new URI for all token types, by relying on the token type ID + * substitution mechanism defined in the MultiToken standard. + * See https://eips.ethereum.org/EIPS/eip-1155#metadata. + * + * @circuitInfo k=10, rows=39 + * + * @notice By this mechanism, any occurrence of the `\{id\}` substring in either the + * URI or any of the values in the JSON file at said URI will be replaced by + * clients with the token type ID. + * + * For example, the `https://token-cdn-domain/\{id\}.json` URI would be + * interpreted by clients as + * `https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json` + * for token type ID 0x4cce0. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Opaque<"string">} newURI - The new base URI for all tokens. + * @return {[]} - Empty tuple. + */ + export circuit _setURI(newURI: Opaque<"string">): [] { + Initializable_assertInitialized(); + + _uri = disclose(newURI); + } + + /** + * @description Creates a `value` amount of tokens of type `token_id`, and assigns them to `to`. + * + * @circuitInfo k=10, rows=912 + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from + * being inadvertently locked in contracts that cannot currently handle token receipt. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not the zero address. + * - `to` is not a ContractAddress. + * + * @param {Either} to - The recipient of the minted tokens. + * @param {Uint<128>} id - The unique identifier for the token type. + * @param {Uint<128>} value - The quantity of `id` tokens that are minted to `to`. + * @return {[]} - Empty tuple. + */ + export circuit _mint(to: Either, id: Uint<128>, value: Uint<128>): [] { + assert(!Utils_isContractAddress(to), "MultiToken: unsafe transfer"); + _unsafeMint(to, id, value); + } + + /** + * @description Unsafe variant of `_mint` which allows transfers to contract addresses. + * + * @circuitInfo k=10, rows=911 + * + * @warning Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * Requirements: + * + * - Contract is initialized. + * - `to` is not the zero address. + * + * @param {Either} to - The recipient of the minted tokens. + * @param {Uint<128>} id - The unique identifier for the token type. + * @param {Uint<128>} value - The quantity of `id` tokens that are minted to `to`. + * @return {[]} - Empty tuple. + */ + export circuit _unsafeMint(to: Either, id: Uint<128>, value: Uint<128>): [] { + Initializable_assertInitialized(); + + assert(!Utils_isKeyOrAddressZero(to), "MultiToken: invalid receiver"); + _update(burnAddress(), to, id, value); + } + + /** + * @description Destroys a `value` amount of tokens of type `token_id` from `from`. + * + * @circuitInfo k=10, rows=688 + * + * Requirements: + * + * - Contract is initialized. + * - `from` is not the zero address. + * - `from` must have an `id` balance of at least `value`. + * + * @param {Either} from - The owner whose tokens will be destroyed. + * @param {Uint<128>} id - The unique identifier of the token type. + * @param {Uint<128>} value - The quantity of `id` tokens that will be destroyed from `from`. + * @return {[]} - Empty tuple. + */ + export circuit _burn(from: Either, id: Uint<128>, value: Uint<128>): [] { + Initializable_assertInitialized(); + + assert(!Utils_isKeyOrAddressZero(from), "MultiToken: invalid sender"); + _update(from, burnAddress(), id, value); + } + + /** + * @description Enables or disables approval for `operator` to manage all of the caller's assets. + * + * @circuitInfo k=10, rows=518 + * + * @notice This circuit does not check for access permissions but can be useful as a building block + * for more complex contract logic. + * + * Requirements: + * + * - Contract is initialized. + * - `operator` is not the zero address. + * + * @param {Either} owner - The ZswapCoinPublicKey or ContractAddress of the target owner. + * @param {Either} operator - The ZswapCoinPublicKey or ContractAddress whose approval is set for the + * `owner`'s assets. + * @param {Boolean} approved - The boolean value determining if the operator may or may not handle the + * `owner`'s assets. + * @return {[]} - Empty tuple. + */ + export circuit _setApprovalForAll( + owner: Either, + operator: Either, + approved: Boolean + ): [] { + Initializable_assertInitialized(); + + assert(!Utils_isKeyOrAddressZero(operator), "MultiToken: invalid operator"); + if (!_operatorApprovals.member(disclose(owner))) { + _operatorApprovals.insert(disclose(owner), default, Boolean>>); + } + + _operatorApprovals.lookup(owner).insert(disclose(operator), disclose(approved)); + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/NonFungibleToken.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/NonFungibleToken.compact new file mode 100644 index 000000000..8cfb153a4 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/NonFungibleToken.compact @@ -0,0 +1,806 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/NonFungibleToken.compact) + +pragma language_version >= 0.18.0; + +/** + * @module NonFungibleToken + * @description An unshielded Non-Fungible Token library. + * + * @notice One notable difference regarding this implementation and the EIP721 spec + * consists of the token size. Uint<128> is used as the token size because Uint<256> + * cannot be supported. + * This is due to encoding limits on the midnight circuit backend: + * https://github.com/midnightntwrk/compactc/issues/929 + * + * @notice At the moment Midnight does not support contract-to-contract communication, but + * there are ongoing efforts to enable this in the future. Thus, the main circuits of this module + * restrict developers from sending tokens to contracts; however, we provide developers + * the ability to experiment with sending tokens to contracts using the `_unsafe` + * transfer methods. Once contract-to-contract communication is available we will follow the + * deprecation plan outlined below: + * + * Initial Minor Version Change: + * + * - Mark _unsafeTransfer as deprecated and emit a warning if possible. + * - Keep its implementation intact so existing callers continue to work. + * + * Later Major Version Change: + * + * - Drop _unsafeTransfer and remove `isContract` guard from `transfer`. + * - By this point, anyone using _unsafeTransfer should have migrated to the now C2C-capable `transfer`. + * + * @notice Missing Features and Improvements: + * + * - Uint256 token IDs + * - Transfer/Approval events + * - safeTransfer functions + * - _baseURI() support + * - An ERC165-like interface + */ + +module NonFungibleToken { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + /// Public state + export sealed ledger _name: Opaque<"string">; + export sealed ledger _symbol: Opaque<"string">; + + /** + * @description Mapping from token IDs to their owner addresses. + * @type {Uint<128>} tokenId - The unique identifier for a token. + * @type {Either} owner - The owner address (public key or contract). + * @type {Map} + * @type {Map, Either>} _owners + */ + export ledger _owners: Map, Either>; + + /** + * @description Mapping from account addresses to their token balances. + * @type {Either} owner - The owner address. + * @type {Uint<128>} balance - The balance of the owner. + * @type {Map} + * @type {Map, Uint<128>>} _balances + */ + export ledger _balances: Map, Uint<128>>; + + /** + * @description Mapping from token IDs to approved addresses. + * @type {Uint<128>} tokenId - The unique identifier for a token. + * @type {Either} approved - The approved address (public key or contract). + * @type {Map} + * @type {Map, Either>} _tokenApprovals + */ + export ledger _tokenApprovals: Map, Either>; + + /** + * @description Mapping from owner addresses to operator approvals. + * @type {Either} owner - The owner address. + * @type {Either} operator - The operator address. + * @type {Boolean} approved - Whether the operator is approved. + * @type {Map>} + * @type {Map, Map, Boolean>>} _operatorApprovals + */ + export ledger _operatorApprovals: Map, Map, Boolean>>; + + /** + * @description Mapping from token IDs to their metadata URIs. + * @type {Uint<128>} tokenId - The unique identifier for a token. + * @type {Opaque<"string">} uri - The metadata URI for the token. + * @type {Map} + * @type {Map, Opaque<"string">>} _tokenURIs + */ + export ledger _tokenURIs: Map, Opaque<"string">>; + + /** + * @description Initializes the contract by setting the name and symbol. + * + * This MUST be called in the implementing contract's constructor. + * Failure to do so can lead to an irreparable contract. + * + * @circuitInfo k=10, rows=65 + * + * Requirements: + * + * - Contract is not initialized. + * + * @param {Opaque<"string">} name_ - The name of the token. + * @param {Opaque<"string">} symbol_ - The symbol of the token. + * @return {[]} - Empty tuple. + */ + export circuit initialize(name_: Opaque<"string">, symbol_: Opaque<"string">): [] { + Initializable_initialize(); + _name = disclose(name_); + _symbol = disclose(symbol_); + } + + /** + * @description Returns the number of tokens in `owner`'s account. + * + * @circuitInfo k=10, rows=309 + * + * Requirements: + * + * - The contract is initialized. + * + * @param {Either)} owner - The account to query. + * @return {Uint<128>} - The number of tokens in `owner`'s account. + */ + export circuit balanceOf(owner: Either): Uint<128> { + Initializable_assertInitialized(); + if (!_balances.member(disclose(owner))) { + return 0; + } + + return _balances.lookup(disclose(owner)); + } + + /** + * @description Returns the owner of the `tokenId` token. + * + * @circuitInfo k=10, rows=290 + * + * Requirements: + * + * - The contract is initialized. + * - The `tokenId` must exist. + * + * @param {Uint<128>} tokenId - The identifier for a token. + * @return {Either} - The account that owns the token. + */ + export circuit ownerOf(tokenId: Uint<128>): Either { + Initializable_assertInitialized(); + return _requireOwned(tokenId); + } + + /** + * @description Returns the token name. + * + * @circuitInfo k=10, rows=36 + * + * Requirements: + * + * - The contract is initialized. + * + * @return {Opaque<"string">} - The token name. + */ + export circuit name(): Opaque<"string"> { + Initializable_assertInitialized(); + return _name; + } + + /** + * @description Returns the symbol of the token. + * + * @circuitInfo k=10, rows=36 + * + * Requirements: + * + * - The contract is initialized. + * + * @return {Opaque<"string">} - The token symbol. + */ + export circuit symbol(): Opaque<"string"> { + Initializable_assertInitialized(); + return _symbol; + } + + /** + * @description Returns the token URI for the given `tokenId`. Returns the empty + * string if a tokenURI does not exist. + * + * @circuitInfo k=10, rows=296 + * + * Requirements: + * + * - The contract is initialized. + * - The `tokenId` must exist. + * + * @notice Native strings and string operations aren't supported within the Compact language, + * e.g. concatenating a base URI + token ID is not possible like in other NFT implementations. + * Therefore, we propose the URI storage approach; whereby, NFTs may or may not have unique "base" URIs. + * It's up to the implementation to decide on how to handle this. + * + * @param {Uint<128>} tokenId - The identifier for a token. + * @return {Opaque<"string">} - the token id's URI. + */ + export circuit tokenURI(tokenId: Uint<128>): Opaque<"string"> { + Initializable_assertInitialized(); + _requireOwned(tokenId); + + if (!_tokenURIs.member(disclose(tokenId))) { + return Utils_emptyString(); + } + + return _tokenURIs.lookup(disclose(tokenId)); + } + + /** + * @description Sets the the URI as `tokenURI` for the given `tokenId`. + * + * @circuitInfo k=10, rows=253 + * + * Requirements: + * + * - The contract is initialized. + * - The `tokenId` must exist. + * + * @notice The URI for a given NFT is usually set when the NFT is minted. + * + * @param {Uint<128>} tokenId - The identifier of the token. + * @param {Opaque<"string">} tokenURI - The URI of `tokenId`. + * @return {[]} - Empty tuple. + */ + export circuit _setTokenURI(tokenId: Uint<128>, tokenURI: Opaque<"string">): [] { + Initializable_assertInitialized(); + _requireOwned(tokenId); + + return _tokenURIs.insert(disclose(tokenId), disclose(tokenURI)); + } + + /** + * @description Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * @circuitInfo k=10, rows=966 + * + * Requirements: + * + * - The contract is initialized. + * - The caller must either own the token or be an approved operator. + * - `tokenId` must exist. + * + * @param {Either} to - The account receiving the approval + * @param {Uint<128>} tokenId - The token `to` may be permitted to transfer + * @return {[]} - Empty tuple. + */ + export circuit approve( + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + const auth = left(ownPublicKey()); + _approve( + to, + tokenId, + auth + ); + } + + /** + * @description Returns the account approved for `tokenId` token. + * + * @circuitInfo k=10, rows=409 + * + * Requirements: + * + * - The contract is initialized. + * - `tokenId` must exist. + * + * @param {Uint<128>} tokenId - The token an account may be approved to manage + * @return {Either} Operator- The account approved to manage the token + */ + export circuit getApproved(tokenId: Uint<128>): Either { + Initializable_assertInitialized(); + _requireOwned(tokenId); + + return _getApproved(tokenId); + } + + /** + * @description Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} for any token owned by the caller. + * + * @circuitInfo k=10, rows=409 + * + * Requirements: + * + * - The contract is initialized. + * - The `operator` cannot be the address zero. + * + * @param {Either} operator - An operator to manage the caller's tokens + * @param {Boolean} approved - A boolean determining if `operator` may manage all tokens of the caller + * @return {[]} - Empty tuple. + */ + export circuit setApprovalForAll( + operator: Either, + approved: Boolean + ): [] { + Initializable_assertInitialized(); + const owner = left(ownPublicKey()); + _setApprovalForAll( + owner, + operator, + approved + ); + } + + /** + * @description Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * @circuitInfo k=10, rows=621 + * + * Requirements: + * + * - The contract is initialized. + * + * @param {Either} owner - The owner of a token + * @param {Either} operator - An account that may operate on `owner`'s tokens + * @return {Boolean} - A boolean determining if `operator` is allowed to manage all of the tokens of `owner` + */ + export circuit isApprovedForAll( + owner: Either, + operator: Either + ): Boolean { + Initializable_assertInitialized(); + if (_operatorApprovals.member(disclose(owner)) && _operatorApprovals.lookup(owner).member(disclose(operator))) { + return _operatorApprovals.lookup(owner).lookup(disclose(operator)); + } else { + return false; + } + } + + /** + * @description Transfers `tokenId` token from `from` to `to`. + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract interactions + * are supported in Compact. This restriction prevents assets from being inadvertently locked in contracts that cannot + * currently handle token receipt. + * + * @circuitInfo k=11, rows=1966 + * + * Requirements: + * + * - The contract is initialized. + * - `from` is not the zero address. + * - `to` is not the zero address. + * - `to` is not a ContractAddress. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * @param {Either} from - The source account from which the token is being transfered + * @param {Either} to - The target account to transfer token to + * @param {Uint<128>} tokenId - The token being transfered + * @return {[]} - Empty tuple. + */ + export circuit transferFrom( + from: Either, + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "NonFungibleToken: Unsafe Transfer"); + + _unsafeTransferFrom(from, to, tokenId); + } + + /** + * @description Transfers `tokenId` token from `from` to `to`. It does NOT check if the recipient is a ContractAddress. + * + * WARNING: Transfers to contract addresses are considered unsafe because contract-to-contract calls + * are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * @circuitInfo k=11, rows=1963 + * + * Requirements: + * + * - The contract is initialized. + * - `from` is not the zero address. + * - `to` is not the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * @param {Either} from - The source account from which the token is being transfered + * @param {Either} to - The target account to transfer token to + * @param {Uint<128>} tokenId - The token being transfered + * @return {[]} - Empty tuple. + */ + export circuit _unsafeTransferFrom( + from: Either, + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(to), "NonFungibleToken: Invalid Receiver"); + // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists + // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. + const auth = left(ownPublicKey()); + const previousOwner = _update( + to, + tokenId, + auth + ); + assert(previousOwner == from, "NonFungibleToken: Incorrect Owner"); + } + + /** + * @description Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + * + * @circuitInfo k=10, rows=253 + * + * Requirements: + * + * - The contract is initialized. + * + * @param {Uint<128>} tokenId - The target token of the owner query + * @return {Either} - The owner of the token + */ + export circuit _ownerOf(tokenId: Uint<128>): Either { + Initializable_assertInitialized(); + if (!_owners.member(disclose(tokenId))) { + return burnAddress(); + } + + return _owners.lookup(disclose(tokenId)); + } + + /** + * @description Returns the approved address for `tokenId`. Returns the zero address if `tokenId` is not minted. + * + * @circuitInfo k=10, rows=253 + * + * Requirements: + * + * - The contract is initialized. + * + * @param {Uint<128>} tokenId - The token to query + * @return {Either} - An account approved to spend `tokenId` + */ + export circuit _getApproved(tokenId: Uint<128>): Either { + Initializable_assertInitialized(); + if (!_tokenApprovals.member(disclose(tokenId))) { + return burnAddress(); + } + return _tokenApprovals.lookup(disclose(tokenId)); + } + + /** + * @description Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in + * particular (ignoring whether it is owned by `owner`). + * + * @circuitInfo k=11, rows=1098 + * + * Requirements: + * + * - The contract is initialized. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + * + * @param {Either} owner - Owner of the token + * @param {Either} spender - Account that wishes to spend `tokenId` + * @param {Uint<128>} tokenId - Token to spend + * @return {Boolean} - A boolean determining if `spender` may manage `tokenId` + */ + export circuit _isAuthorized( + owner: Either, + spender: Either, + tokenId: Uint<128> + ): Boolean { + Initializable_assertInitialized(); + return ( + !Utils_isKeyOrAddressZero(disclose(spender)) && + (disclose(owner) == disclose(spender) || isApprovedForAll(owner, spender) || _getApproved(tokenId) == disclose(spender)) + ); + } + + /** + * @description Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner. + * + * @circuitInfo k=11, rows=1121 + * + * Requirements: + * + * - The contract is initialized. + * - `spender` has approval from `owner` for `tokenId` OR `spender` has approval to manage all of `owner`'s assets. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + * + * @param {Either} owner - Owner of the token + * @param {Either} spender - Account operating on `tokenId` + * @param {Uint<128>} tokenId - The token to spend + * @return {[]} - Empty tuple. + */ + export circuit _checkAuthorized( + owner: Either, + spender: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + if (!_isAuthorized(owner, spender, tokenId)) { + assert(!Utils_isKeyOrAddressZero(owner), "NonFungibleToken: Nonexistent Token"); + assert(false, "NonFungibleToken: Insufficient Approval"); + } + } + + /** + * @description Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner + * (or `to`) is the zero address. Returns the owner of the `tokenId` before the update. + * + * @circuitInfo k=11, rows=1959 + * + * Requirements: + * + * - The contract is initialized. + * - If `auth` is non 0, then this function will check that `auth` is either the owner of the token, + * or approved to operate on the token (by the owner). + * + * @param {Either} to - The intended recipient of the token transfer + * @param {Uint<128>} tokenId - The token being transfered + * @param {Either} auth - An account authorized to transfer the token + * @return {Either} - Owner of the token before it was transfered + */ + circuit _update( + to: Either, + tokenId: Uint<128>, + auth: Either + ): Either { + Initializable_assertInitialized(); + const from = _ownerOf(tokenId); + + // Perform (optional) operator check + if (!Utils_isKeyOrAddressZero(disclose(auth))) { + _checkAuthorized(from, auth, tokenId); + } + + // Execute the update + if (!Utils_isKeyOrAddressZero(disclose(from))) { + // Clear approval. No need to re-authorize + _approve(burnAddress(), tokenId, burnAddress()); + const newBalance = _balances.lookup(disclose(from)) - 1 as Uint<128>; + _balances.insert(disclose(from), disclose(newBalance)); + } + + if (!Utils_isKeyOrAddressZero(disclose(to))) { + if (!_balances.member(disclose(to))) { + _balances.insert(disclose(to), 0); + } + const newBalance = _balances.lookup(disclose(to)) + 1 as Uint<128>; + _balances.insert(disclose(to), disclose(newBalance)); + } + + _owners.insert(disclose(tokenId), disclose(to)); + + return from; + } + + /** + * @description Mints `tokenId` and transfers it to `to`. + * + * @circuitInfo k=11, rows=1013 + * + * Requirements: + * + * - The contract is initialized. + * - `tokenId` must not exist. + * - `to` is not the zero address. + * - `to` is not a ContractAddress. + * + * @param {Either} to - The account receiving `tokenId` + * @param {Uint<128>} tokenId - The token to transfer + * @return {[]} - Empty tuple. + */ + export circuit _mint( + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "NonFungibleToken: Unsafe Transfer"); + + _unsafeMint(to, tokenId); + } + + /** + * @description Mints `tokenId` and transfers it to `to`. It does NOT check if the recipient is a ContractAddress. + * + * @circuitInfo k=11, rows=1010 + * + * Requirements: + * + * - The contract is initialized. + * - `tokenId` must not exist. + * - `to` is not the zero address. + * + * WARNING: Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * @param {Either} to - The account receiving `tokenId` + * @param {Uint<128>} tokenId - The token to transfer + * @return {[]} - Empty tuple. + */ + export circuit _unsafeMint( + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(to), "NonFungibleToken: Invalid Receiver"); + + const previousOwner = _update(to, tokenId, burnAddress()); + + assert(Utils_isKeyOrAddressZero(previousOwner), "NonFungibleToken: Invalid Sender"); + } + + /** + * @description Destroys `tokenId`. + * The approval is cleared when the token is burned. + * This circuit does not check if the sender is authorized to operate on the token. + * + * @circuitInfo k=10, rows=479 + * + * Requirements: + * + * - The contract is initialized. + * - `tokenId` must exist. + * + * @param {Uint<128>} tokenId - The token to burn + * @return {[]} - Empty tuple. + */ + export circuit _burn(tokenId: Uint<128>): [] { + Initializable_assertInitialized(); + const previousOwner = _update(burnAddress(), tokenId, burnAddress()); + assert(!Utils_isKeyOrAddressZero(previousOwner), "NonFungibleToken: Invalid Sender"); + } + + /** + * @description Transfers `tokenId` from `from` to `to`. + * As opposed to {transferFrom}, this imposes no restrictions on ownPublicKey(). + * + * @notice Transfers to contract addresses are currently disallowed until contract-to-contract + * interactions are supported in Compact. This restriction prevents assets from being inadvertently + * locked in contracts that cannot currently handle token receipt. + * + * @circuitInfo k=11, rows=1224 + * + * Requirements: + * + * - The contract is initialized. + * - `to` is not the zero address. + * - `to` is not a ContractAddress. + * - `tokenId` token must be owned by `from`. + * + * @param {Either} from - The source account of the token transfer + * @param {Either} to - The target account of the token transfer + * @param {Uint<128>} tokenId - The token to transfer + * @return {[]} - Empty tuple. + */ + export circuit _transfer( + from: Either, + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isContractAddress(to), "NonFungibleToken: Unsafe Transfer"); + + _unsafeTransfer(from, to, tokenId); + } + + /** + * @description Transfers `tokenId` from `from` to `to`. + * As opposed to {_unsafeTransferFrom}, this imposes no restrictions on ownPublicKey(). + * It does NOT check if the recipient is a ContractAddress. + * + * @circuitInfo k=11, rows=1221 + * + * Requirements: + * + * - The contract is initialized. + * - `to` is not the zero address. + * - `tokenId` token must be owned by `from`. + * + * WARNING: Transfers to contract addresses are considered unsafe because contract-to-contract + * calls are not currently supported. Tokens sent to a contract address may become irretrievable. + * Once contract-to-contract calls are supported, this circuit may be deprecated. + * + * @param {Either} from - The source account of the token transfer + * @param {Either} to - The target account of the token transfer + * @param {Uint<128>} tokenId - The token to transfer + * @return {[]} - Empty tuple. + */ + export circuit _unsafeTransfer( + from: Either, + to: Either, + tokenId: Uint<128> + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(to), "NonFungibleToken: Invalid Receiver"); + + const previousOwner = _update(to, tokenId, burnAddress()); + + assert(!Utils_isKeyOrAddressZero(previousOwner), "NonFungibleToken: Nonexistent Token"); + assert(previousOwner == from, "NonFungibleToken: Incorrect Owner"); + } + + /** + * @description Approve `to` to operate on `tokenId` + * + * @circuitInfo k=11, rows=1109 + * + * Requirements: + * + * - The contract is initialized. + * - If `auth` is non 0, then this function will check that `auth` is either the owner of the token, + * or approved to operate on the token (by the owner). + * + * @param {Either} to - The target account to approve + * @param {Uint<128>} tokenId - The token to approve + * @param {Either} auth - An account authorized to operate on all tokens held by the owner the token + * @return {[]} - Empty tuple. + */ + export circuit _approve( + to: Either, + tokenId: Uint<128>, + auth: Either + ): [] { + Initializable_assertInitialized(); + if (!Utils_isKeyOrAddressZero(disclose(auth))) { + const owner = _requireOwned(tokenId); + + // We do not use _isAuthorized because single-token approvals should not be able to call approve + assert((owner == disclose(auth) || isApprovedForAll(owner, auth)), "NonFungibleToken: Invalid Approver"); + } + + _tokenApprovals.insert(disclose(tokenId), disclose(to)); + } + + /** + * @description Approve `operator` to operate on all of `owner` tokens + * + * @circuitInfo k=10, rows=524 + * + * Requirements: + * + * - The contract is initialized. + * - `operator` is not the address zero. + * + * @param {Either} owner - Owner of a token + * @param {Either} operator - The account to approve + * @param {Boolean} approved - A boolean determining if `operator` may operate on all of `owner` tokens + * @return {[]} - Empty tuple. + */ + export circuit _setApprovalForAll( + owner: Either, + operator: Either, + approved: Boolean + ): [] { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(operator), "NonFungibleToken: Invalid Operator"); + + if (!_operatorApprovals.member(disclose(owner))) { + _operatorApprovals.insert( + disclose(owner), + default, Boolean>> + ); + } + + _operatorApprovals.lookup(owner).insert(disclose(operator), disclose(approved)); + } + + /** + * @description Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Returns the owner. + * + * @circuitInfo k=10, rows=288 + * + * Requirements: + * + * - The contract is initialized. + * - `tokenId` must exist. + * + * @param {Uint<128>} tokenId - The token that should be owned + * @return {Either} - The owner of `tokenId` + */ + export circuit _requireOwned(tokenId: Uint<128>): Either { + Initializable_assertInitialized(); + const owner = _ownerOf(tokenId); + + assert(!Utils_isKeyOrAddressZero(owner), "NonFungibleToken: Nonexistent Token"); + return owner; + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/FungibleTokenWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/FungibleTokenWitnesses.ts new file mode 100644 index 000000000..866077388 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/FungibleTokenWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/witnesses/FungibleTokenWitnesses.ts) + +// This is how we type an empty object. +export type FungibleTokenPrivateState = Record; +export const FungibleTokenWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/MultiTokenWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/MultiTokenWitnesses.ts new file mode 100644 index 000000000..1ba0331ca --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/MultiTokenWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/witnesses/MultiTokenWitnesses.ts) + +// This is how we type an empty object. +export type MultiTokenPrivateState = Record; +export const MultiTokenWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/NonFungibleTokenWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/NonFungibleTokenWitnesses.ts new file mode 100644 index 000000000..5fec649ed --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/token/witnesses/NonFungibleTokenWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (token/witnesses/NonFungibleToken.ts) + +// This is how we type an empty object. +export type NonFungibleTokenPrivateState = Record; +export const NonFungibleTokenWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/Utils.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/Utils.compact new file mode 100644 index 000000000..42980f396 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/Utils.compact @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (utils/Utils.compact) + +pragma language_version >= 0.18.0; + +/** + * @module Utils. + * @description A library for common utilities used in Compact contracts. + */ +module Utils { + import CompactStandardLibrary; + + /** + * @description Returns whether `keyOrAddress` is the zero address. + * + * @notice Midnight's burn address is represented as left(default) + * in Compact, so we've chosen to represent the zero address as this structure as well. + * + * @param {Either} keyOrAddress - The target value to check, either a ZswapCoinPublicKey or a ContractAddress. + * @return {Boolean} - Returns true if `keyOrAddress` is zero. + */ + export pure circuit isKeyOrAddressZero(keyOrAddress: Either): Boolean { + return isContractAddress(keyOrAddress) + ? default == keyOrAddress.right + : default == keyOrAddress.left; + } + + /** + * @description Returns whether `key` is the zero address. + * + * @param {ZswapCoinPublicKey} key - A ZswapCoinPublicKey + * @return {Boolean} - Returns true if `key` is zero. + */ + export pure circuit isKeyZero(key: ZswapCoinPublicKey): Boolean { + const zero = default; + return zero == key; + } + + /** + * @description Returns whether `keyOrAddress` is equal to `other`. Assumes that a ZswapCoinPublicKey + * and a ContractAddress can never be equal + * + * @param {Either} keyOrAddress - The target value to check, either a ZswapCoinPublicKey or a ContractAddress. + * @param {Either} other - The other value to check, either a ZswapCoinPublicKey or a ContractAddress. + * @return {Boolean} - Returns true if `keyOrAddress` is is equal to `other`. + */ + export pure circuit isKeyOrAddressEqual( + keyOrAddress: Either, + other: Either + ): Boolean { + if (keyOrAddress.is_left && other.is_left) { + return keyOrAddress.left == other.left; + } else if (!keyOrAddress.is_left && !other.is_left) { + return keyOrAddress.right == other.right; + } else { + return false; + } + } + + /** + * @description Returns whether `keyOrAddress` is a ContractAddress type. + * + * @param {Either} keyOrAddress - The target value to check, either a ZswapCoinPublicKey or a ContractAddress. + * @return {Boolean} - Returns true if `keyOrAddress` is a ContractAddress. + */ + export pure circuit isContractAddress(keyOrAddress: Either): Boolean { + return !keyOrAddress.is_left; + } + + /** + * @description A helper function that returns the empty string: "". + * + * @return {Opaque<"string">} - The empty string: "". + */ + export pure circuit emptyString(): Opaque<"string"> { + return default>; + } +} diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/witnesses/UtilsWitnesses.ts b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/witnesses/UtilsWitnesses.ts new file mode 100644 index 000000000..e8b278778 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/base-contracts/src/utils/witnesses/UtilsWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (utils/witnesses/UtilsWitnesses.ts) + +// This is how we type an empty object. +export type UtilsPrivateState = Record; +export const UtilsWitnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/contract-name.compact b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/contract-name.compact new file mode 100644 index 000000000..e69de29bb diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/index.original.ts.rename b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/index.original.ts.rename new file mode 100644 index 000000000..eb4213351 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/index.original.ts.rename @@ -0,0 +1,2 @@ +export * as [contract-code-name] from "./managed/[contract-name]/contract/index.cjs"; +export * from "./witnesses.ts"; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/witnesses.ts.rename b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/witnesses.ts.rename new file mode 100644 index 000000000..3e812d679 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/contract-template/src/witnesses.ts.rename @@ -0,0 +1 @@ +export const witnesses = {}; diff --git a/packages/chains/midnight-contracts/src/scaffold/template/deno.json.rename b/packages/chains/midnight-contracts/src/scaffold/template/deno.json.rename new file mode 100644 index 000000000..c5fd824fe --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/deno.json.rename @@ -0,0 +1,28 @@ +{ + "name": "@[scope]/midnight-contracts", + "version": "0.3.0", + "exports": { + "./[contract-name]": "./contract-[contract-name]/src/index.original.ts" + }, + "tasks": { + "midnight-node:start": "CFG_PRESET=dev deno run -A --unstable-detect-cjs npm:@paimaexample/npm-midnight-node --dev --rpc-port 9944 --state-pruning archive --blocks-pruning archive --public-addr /ip4/127.0.0.1 --unsafe-rpc-external", + "midnight-node:wait": "wait-on tcp:9944", + "midnight-indexer:start": "CONFIG_FILE=`pwd`/indexer-standalone/config.yaml LEDGER_NETWORK_ID=\"Undeployed\" SUBSTRATE_NODE_WS_URL=\"ws://localhost:9944\" APP__INFRA__SECRET=$(openssl rand -hex 32 | tr 'a-f' 'A-F') FEATURES_WALLET_ENABLED=\"true\" APP__INFRA__NODE__URL=\"ws://localhost:9944\" deno run -A --unstable-detect-cjs npm:@paimaexample/npm-midnight-indexer --binary --clean", + "midnight-indexer:wait": "wait-on tcp:8088", + "midnight-proof-server:start": "LEDGER_NETWORK_ID=\"Undeployed\" RUST_BACKTRACE=full SUBSTRATE_NODE_WS_URL=\"ws://localhost:9944\" deno run -A --unstable-detect-cjs npm:@paimaexample/npm-midnight-proof-server", + "midnight-proof-server:wait": "wait-on tcp:6300", + "midnight-contract:clean": "rm -rf midnight-level-db && rm -rf contract.json", + "midnight-contract:deploy": "deno task midnight-contract:clean && deno --unstable-detect-cjs -A deploy.ts", + "midnight-faucet:start": "deno run -A faucet.ts" + }, + "imports": { + "@paimaexample/npm-midnight-indexer": "npm:@paimaexample/npm-midnight-indexer@[EFFECTSTREAM-VERSION]", + "@paimaexample/npm-midnight-node": "npm:@paimaexample/npm-midnight-node@[EFFECTSTREAM-VERSION]", + "@paimaexample/npm-midnight-proof-server": "npm:@paimaexample/npm-midnight-proof-server@[EFFECTSTREAM-VERSION]", + "@midnight-ntwrk/ledger": "npm:@midnight-ntwrk/ledger", + "@midnight-ntwrk/compact-runtime": "npm:@midnight-ntwrk/compact-runtime@0.9.0", + "@midnight-ntwrk/zswap": "npm:@midnight-ntwrk/zswap", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@std/path": "jsr:@std/path@^1.1.2" + } +} \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/template/deploy.ts.rename b/packages/chains/midnight-contracts/src/scaffold/template/deploy.ts.rename new file mode 100644 index 000000000..ff0ffb6a6 --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/deploy.ts.rename @@ -0,0 +1,22 @@ +import { deployMidnightContract, type DeployConfig } from "@paimaexample/midnight-contracts/deploy"; + +/** MIDNIGHT-DEPLOY-IMPORTS */ + +const configs: DeployConfig[] = [ + /** MIDNIGHT-DEPLOY-CONFIG */ +]; + +const start = async () => { + for (const config of configs) { + await deployMidnightContract(config); + } +} + +start() + .then(() => { + console.log("Deployment successful"); + Deno.exit(0); + }).catch((e: unknown) => { + console.error("Unhandled error:", e); + Deno.exit(1); + }); \ No newline at end of file diff --git a/packages/chains/midnight-contracts/src/scaffold/template/indexer-standalone/config.yaml b/packages/chains/midnight-contracts/src/scaffold/template/indexer-standalone/config.yaml new file mode 100644 index 000000000..c539cac7f --- /dev/null +++ b/packages/chains/midnight-contracts/src/scaffold/template/indexer-standalone/config.yaml @@ -0,0 +1,44 @@ +run_migrations: true +network_id: &network_id "Undeployed" + +chain_indexer_application: + network_id: *network_id + blocks_buffer: 60 + save_zswap_state_after: 1000 + caught_up_max_distance: 60 + caught_up_leeway: 30 + +wallet_indexer_application: + network_id: *network_id + active_wallets_repeat_delay: "100ms" + active_wallets_ttl: "30m" + transaction_batch_size: 10 + # Number of cores by default. + # parallelism: + +infra: + node: + url: "ws://localhost:9944" + reconnect_max_delay: "10s" # 10ms, 100ms, 1s, 10s + reconnect_max_attempts: 30 # Roughly 5m + + storage: + cnn_url: "./indexer.sqlite" + + api: + address: "0.0.0.0" + port: 8088 + request_body_limit: "1MiB" + max_complexity: 200 + max_depth: 15 + network_id: *network_id + +telemetry: + tracing: + enabled: false + service_name: "indexer" + otlp_exporter_endpoint: "http://localhost:4317" + metrics: + enabled: false + address: "0.0.0.0" + port: 9000