From b744c1abe9470ef173bc8270e6f13a88e92819fa Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 11:48:46 -0300 Subject: [PATCH 01/89] Move the Beginner example under beginner/ and repoint its imports --- .../onboarding/lectures/beginner/2-utxos-and-transactions.md | 2 +- .../onboarding/lectures/beginner/3-time-on-cardano.md | 2 +- .../lectures/beginner/4-native-scripts-and-metadata.md | 4 ++-- .../lectures/beginner/5-tokens-fungible-and-nfts.md | 2 +- docs/developers/onboarding/lectures/beginner/introduction.md | 4 ++-- examples/onboarding/lectures/{ => beginner}/mesh/.gitignore | 0 examples/onboarding/lectures/{ => beginner}/mesh/README.md | 4 ++-- examples/onboarding/lectures/{ => beginner}/mesh/index.html | 0 examples/onboarding/lectures/{ => beginner}/mesh/package.json | 0 examples/onboarding/lectures/{ => beginner}/mesh/src/app.ts | 0 .../lectures/{ => beginner}/mesh/src/connect-wallet.ts | 0 .../onboarding/lectures/{ => beginner}/mesh/src/mint-token.ts | 0 .../lectures/{ => beginner}/mesh/src/native-script.ts | 0 .../onboarding/lectures/{ => beginner}/mesh/src/send-ada.ts | 0 .../lectures/{ => beginner}/mesh/src/send-with-deadline.ts | 0 .../lectures/{ => beginner}/mesh/src/send-with-metadata.ts | 0 .../onboarding/lectures/{ => beginner}/mesh/tsconfig.json | 0 .../onboarding/lectures/{ => beginner}/mesh/vite.config.ts | 0 18 files changed, 9 insertions(+), 9 deletions(-) rename examples/onboarding/lectures/{ => beginner}/mesh/.gitignore (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/README.md (94%) rename examples/onboarding/lectures/{ => beginner}/mesh/index.html (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/package.json (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/app.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/connect-wallet.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/mint-token.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/native-script.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/send-ada.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/send-with-deadline.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/src/send-with-metadata.ts (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/tsconfig.json (100%) rename examples/onboarding/lectures/{ => beginner}/mesh/vite.config.ts (100%) diff --git a/docs/developers/onboarding/lectures/beginner/2-utxos-and-transactions.md b/docs/developers/onboarding/lectures/beginner/2-utxos-and-transactions.md index 47ce139d96..41fc3ab61d 100644 --- a/docs/developers/onboarding/lectures/beginner/2-utxos-and-transactions.md +++ b/docs/developers/onboarding/lectures/beginner/2-utxos-and-transactions.md @@ -8,7 +8,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import extractRegion from "@site/src/utils/extractRegion"; -import SendAda from "!!raw-loader!@site/examples/onboarding/lectures/mesh/src/send-ada.ts"; +import SendAda from "!!raw-loader!@site/examples/onboarding/lectures/beginner/mesh/src/send-ada.ts"; # UTxOs & Transactions diff --git a/docs/developers/onboarding/lectures/beginner/3-time-on-cardano.md b/docs/developers/onboarding/lectures/beginner/3-time-on-cardano.md index 59803c48b8..3cdf0b68c4 100644 --- a/docs/developers/onboarding/lectures/beginner/3-time-on-cardano.md +++ b/docs/developers/onboarding/lectures/beginner/3-time-on-cardano.md @@ -8,7 +8,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import extractRegion from "@site/src/utils/extractRegion"; -import SendWithDeadline from "!!raw-loader!@site/examples/onboarding/lectures/mesh/src/send-with-deadline.ts"; +import SendWithDeadline from "!!raw-loader!@site/examples/onboarding/lectures/beginner/mesh/src/send-with-deadline.ts"; # Time on Cardano diff --git a/docs/developers/onboarding/lectures/beginner/4-native-scripts-and-metadata.md b/docs/developers/onboarding/lectures/beginner/4-native-scripts-and-metadata.md index 77376a2fe4..8a4bac0da3 100644 --- a/docs/developers/onboarding/lectures/beginner/4-native-scripts-and-metadata.md +++ b/docs/developers/onboarding/lectures/beginner/4-native-scripts-and-metadata.md @@ -8,8 +8,8 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import extractRegion from "@site/src/utils/extractRegion"; -import NativeScript from "!!raw-loader!@site/examples/onboarding/lectures/mesh/src/native-script.ts"; -import SendWithMetadata from "!!raw-loader!@site/examples/onboarding/lectures/mesh/src/send-with-metadata.ts"; +import NativeScript from "!!raw-loader!@site/examples/onboarding/lectures/beginner/mesh/src/native-script.ts"; +import SendWithMetadata from "!!raw-loader!@site/examples/onboarding/lectures/beginner/mesh/src/send-with-metadata.ts"; # Native scripts & metadata diff --git a/docs/developers/onboarding/lectures/beginner/5-tokens-fungible-and-nfts.md b/docs/developers/onboarding/lectures/beginner/5-tokens-fungible-and-nfts.md index 8dbebcfb9e..676de10e37 100644 --- a/docs/developers/onboarding/lectures/beginner/5-tokens-fungible-and-nfts.md +++ b/docs/developers/onboarding/lectures/beginner/5-tokens-fungible-and-nfts.md @@ -8,7 +8,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import extractRegion from "@site/src/utils/extractRegion"; -import MintToken from "!!raw-loader!@site/examples/onboarding/lectures/mesh/src/mint-token.ts"; +import MintToken from "!!raw-loader!@site/examples/onboarding/lectures/beginner/mesh/src/mint-token.ts"; # Tokens: fungible & NFTs diff --git a/docs/developers/onboarding/lectures/beginner/introduction.md b/docs/developers/onboarding/lectures/beginner/introduction.md index ad62e9ec8d..6bb9ac42ae 100644 --- a/docs/developers/onboarding/lectures/beginner/introduction.md +++ b/docs/developers/onboarding/lectures/beginner/introduction.md @@ -42,8 +42,8 @@ From lecture 2 onwards you run real transactions on Cardano's free test network. You'll need **[Lace](https://www.lace.io/)** on the **Preview** network with a little test ADA, which is what [lecture 1](/docs/developers/onboarding/lectures/beginner/wallets-keys-addresses) sets up. Then grab the app (no need to clone the whole repo) and start it: ```bash -npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/mesh lectures-mesh -cd lectures-mesh +npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/beginner/mesh beginner-mesh +cd beginner-mesh npm install npm run dev ``` diff --git a/examples/onboarding/lectures/mesh/.gitignore b/examples/onboarding/lectures/beginner/mesh/.gitignore similarity index 100% rename from examples/onboarding/lectures/mesh/.gitignore rename to examples/onboarding/lectures/beginner/mesh/.gitignore diff --git a/examples/onboarding/lectures/mesh/README.md b/examples/onboarding/lectures/beginner/mesh/README.md similarity index 94% rename from examples/onboarding/lectures/mesh/README.md rename to examples/onboarding/lectures/beginner/mesh/README.md index 38a92b6dc7..b23c260dcc 100644 --- a/examples/onboarding/lectures/mesh/README.md +++ b/examples/onboarding/lectures/beginner/mesh/README.md @@ -5,8 +5,8 @@ Small, self-contained [Mesh](https://meshsdk.dev/) snippets used by the lectures Get just this folder (no need to clone the whole repo): ```bash -npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/mesh lectures-mesh -cd lectures-mesh +npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/beginner/mesh beginner-mesh +cd beginner-mesh ``` The snippets run in the **browser** with a connected wallet (CIP-30), so there is no offline test to run; instead `npm test` type-checks them against the real Mesh types so they stay valid: diff --git a/examples/onboarding/lectures/mesh/index.html b/examples/onboarding/lectures/beginner/mesh/index.html similarity index 100% rename from examples/onboarding/lectures/mesh/index.html rename to examples/onboarding/lectures/beginner/mesh/index.html diff --git a/examples/onboarding/lectures/mesh/package.json b/examples/onboarding/lectures/beginner/mesh/package.json similarity index 100% rename from examples/onboarding/lectures/mesh/package.json rename to examples/onboarding/lectures/beginner/mesh/package.json diff --git a/examples/onboarding/lectures/mesh/src/app.ts b/examples/onboarding/lectures/beginner/mesh/src/app.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/app.ts rename to examples/onboarding/lectures/beginner/mesh/src/app.ts diff --git a/examples/onboarding/lectures/mesh/src/connect-wallet.ts b/examples/onboarding/lectures/beginner/mesh/src/connect-wallet.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/connect-wallet.ts rename to examples/onboarding/lectures/beginner/mesh/src/connect-wallet.ts diff --git a/examples/onboarding/lectures/mesh/src/mint-token.ts b/examples/onboarding/lectures/beginner/mesh/src/mint-token.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/mint-token.ts rename to examples/onboarding/lectures/beginner/mesh/src/mint-token.ts diff --git a/examples/onboarding/lectures/mesh/src/native-script.ts b/examples/onboarding/lectures/beginner/mesh/src/native-script.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/native-script.ts rename to examples/onboarding/lectures/beginner/mesh/src/native-script.ts diff --git a/examples/onboarding/lectures/mesh/src/send-ada.ts b/examples/onboarding/lectures/beginner/mesh/src/send-ada.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/send-ada.ts rename to examples/onboarding/lectures/beginner/mesh/src/send-ada.ts diff --git a/examples/onboarding/lectures/mesh/src/send-with-deadline.ts b/examples/onboarding/lectures/beginner/mesh/src/send-with-deadline.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/send-with-deadline.ts rename to examples/onboarding/lectures/beginner/mesh/src/send-with-deadline.ts diff --git a/examples/onboarding/lectures/mesh/src/send-with-metadata.ts b/examples/onboarding/lectures/beginner/mesh/src/send-with-metadata.ts similarity index 100% rename from examples/onboarding/lectures/mesh/src/send-with-metadata.ts rename to examples/onboarding/lectures/beginner/mesh/src/send-with-metadata.ts diff --git a/examples/onboarding/lectures/mesh/tsconfig.json b/examples/onboarding/lectures/beginner/mesh/tsconfig.json similarity index 100% rename from examples/onboarding/lectures/mesh/tsconfig.json rename to examples/onboarding/lectures/beginner/mesh/tsconfig.json diff --git a/examples/onboarding/lectures/mesh/vite.config.ts b/examples/onboarding/lectures/beginner/mesh/vite.config.ts similarity index 100% rename from examples/onboarding/lectures/mesh/vite.config.ts rename to examples/onboarding/lectures/beginner/mesh/vite.config.ts From bffb8a6f642c47441d0e9b0205e8825466dd6765 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 11:48:46 -0300 Subject: [PATCH 02/89] Give extractRegion a #replace directive and tidy its helpers --- src/utils/extractRegion.js | 212 ++++++++++++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/src/utils/extractRegion.js b/src/utils/extractRegion.js index a77c42f0c8..0e314c57de 100644 --- a/src/utils/extractRegion.js +++ b/src/utils/extractRegion.js @@ -1,21 +1,201 @@ -// Extract a named region — the lines between `// #region NAME` and -// `// #endregion NAME` — from a file imported as raw text via raw-loader. -// The markers are plain comments, so the code still runs and is still tested. -// -// import extractRegion from '@site/src/utils/extractRegion'; -// import Source from '!!raw-loader!@site/examples/.../file.ts'; -// {extractRegion(Source, 'build')} - -export default function extractRegion(source, name) { +/** + * Pull a named region out of a source file so docs can show real, tested code. + * + * A region is delimited by comment markers, which keeps the file valid and + * runnable in its own project: + * + * // #region NAME + * ...code... + * // #endregion NAME + * + * Line, block, hash, SQL and HTML comment styles are all recognised. + * + * Four behaviours make the markers a layout tool rather than a straitjacket: + * + * - **Repeated names join.** A name may open and close several times; the parts + * are concatenated in file order, separated by a blank line. Anything between + * them, an explanatory comment say, stays in the file but not in the doc. + * - **Regions nest.** A smaller region may live inside a larger one, and marker + * lines never appear in the output. + * - **Parts can be left out.** Pass the name of a nested region to omit it, so + * one file can serve a page that has met that code and a page that has not. + * - **Values can be swapped.** A `#replace` directive rewrites text on its way + * into the doc, so a file can keep the value its own project needs while the + * page shows the one its reader needs: + * + * // #replace ../../blueprints/vault.plutus.json -> ../../on-chain/plutus.json + * + * Either side may be quoted when it contains spaces. A directive applies to + * the whole file, so every region of it shows the same substitution. + * + * @module extractRegion + */ + +const ANY_REGION_MARKER = /^[^\w]*#(?:end)?region\s+\S+[^\w]*$/; +const REPLACE_MARKER = /^[^\w]*#replace\s+(.+)$/; +const REPLACE_PAIR = /^(.*?)\s+->\s+(.*)$/; +const COMMENT_TAIL = /\s*(?:\*\/|-->)\s*$/; +const QUOTED = /^(["'])(.*)\1$/; + +const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * A marker occupies a whole line: comment punctuation, the tag, the name, and + * whatever closes the comment. Only non-word characters may sit on either side, + * which keeps `types` from matching `types-extra` and keeps prose that merely + * mentions a marker from being treated as one. + * + * @param {string} tag `#region` or `#endregion`. + * @param {string} name Region name to match exactly. + * @returns {RegExp} + */ +const markerFor = (tag, name) => new RegExp(`^[^\\w]*${tag}\\s+${escapeForRegExp(name)}[^\\w]*$`); + +const isBlank = (line) => !line.trim(); + +const unquote = (value) => { + const quoted = QUOTED.exec(value.trim()); + return quoted ? quoted[2] : value.trim(); +}; + +/** + * @param {string[]} lines + * @returns {Array<[string, string]>} `[from, to]` pairs, in file order. + * @throws If a directive is missing its `->`. + */ +const collectReplacements = (lines) => + lines.flatMap((line) => { + const directive = REPLACE_MARKER.exec(line); + if (!directive) return []; + + const pair = REPLACE_PAIR.exec(directive[1].replace(COMMENT_TAIL, '').trim()); + const from = pair && unquote(pair[1]); + if (!from) { + throw new Error(`extractRegion: #replace needs "from -> to", got: ${line.trim()}`); + } + return [[from, unquote(pair[2])]]; + }); + +const applyReplacements = (text, pairs) => + pairs.reduce((result, [from, to]) => result.split(from).join(to), text); + +/** + * @param {string[]} lines + * @param {string} name + * @returns {string[][]} One entry per opening of the region, in file order. + * @throws If the region is opened and never closed. + */ +const collectBlocks = (lines, name) => { + const opens = markerFor('#region', name); + const closes = markerFor('#endregion', name); + const blocks = []; + let start = -1; + + lines.forEach((line, index) => { + if (start === -1) { + if (opens.test(line)) start = index; + } else if (closes.test(line)) { + blocks.push(lines.slice(start + 1, index)); + start = -1; + } + }); + + if (start !== -1) { + throw new Error(`extractRegion: region "${name}" opened at line ${start + 1} and never closed`); + } + return blocks; +}; + +/** + * @param {string[]} block + * @param {string[]} omit + * @returns {{ kept: string[], found: string[] }} The lines that survive, and + * which of the `omit` names were actually present, so the caller can report + * one that matched nothing. + */ +const removeNested = (block, omit) => { + const openers = omit.map((name) => ({ name, pattern: markerFor('#region', name) })); + const closers = new Map(omit.map((name) => [name, markerFor('#endregion', name)])); + const kept = []; + const found = new Set(); + let skipping = null; + + for (const line of block) { + if (skipping) { + if (closers.get(skipping).test(line)) skipping = null; + continue; + } + + const opener = openers.find(({ pattern }) => pattern.test(line)); + if (opener) { + skipping = opener.name; + found.add(opener.name); + continue; + } + kept.push(line); + } + + return { kept, found: [...found] }; +}; + +const trimBlankEdges = (lines) => { + let first = 0; + let last = lines.length; + while (first < last && isBlank(lines[first])) first += 1; + while (last > first && isBlank(lines[last - 1])) last -= 1; + return lines.slice(first, last); +}; + +const collapseBlankRuns = (lines) => + lines.filter((line, index) => !isBlank(line) || (index > 0 && !isBlank(lines[index - 1]))); + +const dedent = (lines) => { + const indents = lines + .filter((line) => !isBlank(line)) + .map((line) => line.length - line.trimStart().length); + const shared = indents.length ? Math.min(...indents) : 0; + return lines.map((line) => line.slice(shared)); +}; + +/** + * @param {string} source File contents, imported with raw-loader. + * @param {string} name Region to extract. + * @param {string|string[]} [omit] Nested regions to leave out. + * @returns {string} The region's code, dedented, with markers and directives + * removed and every `#replace` applied. + * @throws If the region is missing or unclosed, if a `#replace` is malformed, + * or if a name in `omit` is not inside the region, since a typo there would + * silently show code meant to be hidden. + * + * @example + * extractRegion(source, 'validator') // the whole validator + * extractRegion(source, 'validator', 'mint-handler') // ...without that part + * extractRegion(source, 'mint-handler') // only that part + */ +export default function extractRegion(source, name, omit = []) { + const omitted = (Array.isArray(omit) ? omit : [omit]).filter(Boolean); const lines = source.split('\n'); - const start = lines.findIndex((l) => l.includes(`#region ${name}`)); - const end = lines.findIndex((l) => l.includes(`#endregion ${name}`)); - if (start === -1 || end === -1) { + const replacements = collectReplacements(lines); + const blocks = collectBlocks(lines, name); + + if (blocks.length === 0) { throw new Error(`extractRegion: region "${name}" not found`); } - const body = lines.slice(start + 1, end); - const widths = body.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length); - const indent = widths.length ? Math.min(...widths) : 0; - return body.map((l) => l.slice(indent)).join('\n').trim(); + const omittedFound = new Set(); + const parts = blocks.map((block) => { + const { kept, found } = removeNested(block, omitted); + found.forEach((foundName) => omittedFound.add(foundName)); + + const lines = kept.filter((line) => !ANY_REGION_MARKER.test(line) && !REPLACE_MARKER.test(line)); + return collapseBlankRuns(trimBlankEdges(lines)); + }); + + const missing = omitted.filter((omittedName) => !omittedFound.has(omittedName)); + if (missing.length) { + throw new Error(`extractRegion: "${missing.join('", "')}" not found inside region "${name}"`); + } + + const body = parts.flatMap((part, index) => (index ? ['', ...part] : part)); + return applyReplacements(dedent(body).join('\n').trim(), replacements); } From 885da923a56c41165ee41ec06ce3865044bbb6b6 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 11:48:46 -0300 Subject: [PATCH 03/89] Add the vault example: an Aiken contract and a Mesh app --- .../lectures/intermediate/README.md | 41 ++++ .../vault/off-chain/mesh/.env.example | 11 + .../vault/off-chain/mesh/.gitignore | 5 + .../mesh/blueprints/vault.plutus.json | 170 +++++++++++++++ .../vault/off-chain/mesh/index.html | 12 ++ .../vault/off-chain/mesh/package.json | 35 +++ .../vault/off-chain/mesh/server/blockfrost.ts | 90 ++++++++ .../vault/off-chain/mesh/src/app.tsx | 96 +++++++++ .../vault/off-chain/mesh/src/check.ts | 33 +++ .../vault/off-chain/mesh/src/index.css | 1 + .../vault/off-chain/mesh/src/lib/blueprint.ts | 52 +++++ .../vault/off-chain/mesh/src/lib/datum.ts | 21 ++ .../vault/off-chain/mesh/src/lib/fetch.ts | 35 +++ .../vault/off-chain/mesh/src/lib/lock.ts | 38 ++++ .../vault/off-chain/mesh/src/lib/mint.ts | 76 +++++++ .../vault/off-chain/mesh/src/lib/recover.ts | 74 +++++++ .../vault/off-chain/mesh/src/lib/unlock.ts | 77 +++++++ .../vault/off-chain/mesh/src/main.tsx | 204 ++++++++++++++++++ .../vault/off-chain/mesh/src/vault.test.ts | 202 +++++++++++++++++ .../vault/off-chain/mesh/tsconfig.json | 19 ++ .../vault/off-chain/mesh/vault.html | 11 + .../vault/off-chain/mesh/vite.config.ts | 32 +++ .../vault/on-chain/aiken/aiken.lock | 37 ++++ .../vault/on-chain/aiken/aiken.toml | 28 +++ .../vault/on-chain/aiken/plutus.json | 170 +++++++++++++++ .../vault/on-chain/aiken/validators/vault.ak | 172 +++++++++++++++ .../on-chain/aiken/validators/vault_simple.ak | 106 +++++++++ 27 files changed, 1848 insertions(+) create mode 100644 examples/onboarding/lectures/intermediate/README.md create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.env.example create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.gitignore create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/index.html create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/package.json create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/index.css create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vault.html create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak diff --git a/examples/onboarding/lectures/intermediate/README.md b/examples/onboarding/lectures/intermediate/README.md new file mode 100644 index 0000000000..50aeeb67e2 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/README.md @@ -0,0 +1,41 @@ +# Onboarding Intermediate — lock & unlock a validator + +A minimal end-to-end smart contract used by the onboarding **Intermediate** lectures: a spend validator +that locks funds and only releases them to the **owner** named in the datum, proven by a signature. +It's small but a real access-control pattern (the datum is public; the lock is a signature, not a +secret), and a complete on-chain + off-chain example you can run. + +Get just this folder (no need to clone the whole repo): + +```bash +npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/intermediate intermediate +cd intermediate +``` + +## On-chain (Aiken) + +The validator lives in `on-chain/aiken/validators/lock.ak`. The compiled blueprint `plutus.json` is +**committed** (and copied into `off-chain/mesh/`), so you don't need Aiken to run the off-chain code. +To re-check or recompile it: + +```bash +cd on-chain/aiken +aiken check # compile + run the inline tests +aiken build # regenerate plutus.json +cp plutus.json ../../off-chain/mesh/plutus.json +``` + +## Off-chain (Mesh) + browser playground + +```bash +cd off-chain/mesh +npm install +cp .env.example .env # paste your Blockfrost Preview key +npm run dev +``` + +Open the printed URL in the browser where **Lace** (on the **Preview** network, with a little test ADA) +is installed, then: connect → set up collateral → **Lock** funds (you're the owner) → **Unlock** them +(you sign). Each transaction prints an explorer link. + +`npm run typecheck` type-checks the off-chain code against the real Mesh types. diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.env.example b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.env.example new file mode 100644 index 0000000000..04b20bf9fe --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.env.example @@ -0,0 +1,11 @@ +# A Blockfrost project key for the Preview network, create one at https://blockfrost.io +# No VITE_ prefix on purpose: only the backend reads it, never the browser. +BLOCKFROST_API_KEY=previewYourKeyHere + +# Network id: 0 = testnet (Preview / Preprod), 1 = mainnet +# This one is safe to publish, so the browser may read it. +VITE_NETWORK_ID=0 + +# Your own wallet address, for the provider smoke test that opens the +# frontend integration lecture. +MY_ADDRESS=addr_test1... diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.gitignore b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.gitignore new file mode 100644 index 0000000000..bd22d1f98e --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.local diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json new file mode 100644 index 0000000000..c092dcfdf7 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json @@ -0,0 +1,170 @@ +{ + "preamble": { + "title": "cardano-foundation/onboarding-vault", + "description": "The vault built across the onboarding Intermediate lectures", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.23+unknown" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "vault.vault.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/vault~1VaultDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/vault~1VaultAction" + } + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault.vault.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault.vault.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault_simple.vault.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/vault_simple~1VaultDatum" + } + }, + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/vault_simple~1VaultAction" + } + }, + "compiledCode": "58d801010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea80162b3001300a375400b15980099b8748000c024dd5000c4c8cc88cc008008004896600200314a115980099b8f375c601e00200714a31330020023010001402c8070dd618069807180718071807180718071807180718059baa300d008375c601860146ea8c030c028dd5000c5900845900b45900818050009805180580098039baa0018b200a30070013007300800130070013003375400f149a26cac80081", + "hash": "ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570" + }, + { + "title": "vault_simple.vault.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "58d801010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea80162b3001300a375400b15980099b8748000c024dd5000c4c8cc88cc008008004896600200314a115980099b8f375c601e00200714a31330020023010001402c8070dd618069807180718071807180718071807180718059baa300d008375c601860146ea8c030c028dd5000c5900845900b45900818050009805180580098039baa0018b200a30070013007300800130070013003375400f149a26cac80081", + "hash": "ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570" + } + ], + "definitions": { + "Data": { + "title": "Data", + "description": "Any Plutus data." + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "vault/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Recover", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "vault/VaultDatum": { + "title": "VaultDatum", + "anyOf": [ + { + "title": "VaultDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "owner", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + }, + "vault_simple/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + } + ] + }, + "vault_simple/VaultDatum": { + "title": "VaultDatum", + "anyOf": [ + { + "title": "VaultDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "owner", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/index.html b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/index.html new file mode 100644 index 0000000000..78cbf92b9c --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/index.html @@ -0,0 +1,12 @@ + + + + + + Lock and Unlock Intermediate lectures + + +
+ + + diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/package.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/package.json new file mode 100644 index 0000000000..aae261cc5d --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/package.json @@ -0,0 +1,35 @@ +{ + "name": "onboarding-vault-mesh", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Off-chain code + a browser app for the vault contract, built across the onboarding Intermediate lectures with Mesh.", + "scripts": { + "test": "node --test 'src/*.test.ts'", + "typecheck": "tsc", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@meshsdk/core": "^1.9.1", + "@meshsdk/core-csl": "^1.9.1", + "@meshsdk/wallet": "^1.9.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.0.0", + "vite-plugin-node-polyfills": "^0.23.0" + }, + "overrides": { + "libsodium-wrappers-sumo": "^0.8.4" + } +} diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts new file mode 100644 index 0000000000..d021ccab58 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts @@ -0,0 +1,90 @@ +// #region file +/// The backend half of the app, and the only place the Blockfrost key exists. +/// +/// The browser cannot keep a secret: everything Vite ships is readable by +/// whoever opens the page. So the browser never gets the key. It asks this +/// handler instead, and this handler, running on a machine you control, adds +/// the key and forwards the question to Blockfrost. +/// +/// It is a relay, nothing more. It does not build transactions and does not +/// sign anything; the browser still does both. +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; + +// #region handler +// Read `.env` into process.env. The same line the Node scripts use, this file +// is a Node script too, it just happens to be one that answers HTTP. +try { + process.loadEnvFile(); +} catch { + // No .env yet. The check below gives a better message than a crash on boot. +} + +const KEY = process.env.BLOCKFROST_API_KEY ?? ""; + +// A Blockfrost key names its own network: `preview...`, `preprod...`, `mainnet...`. +// Mesh reads the prefix the same way, so one variable configures both halves. +const NETWORK = KEY.slice(0, 7); +const BLOCKFROST = `https://cardano-${NETWORK}.blockfrost.io/api/v0`; + +/// Forward one request to Blockfrost with the key attached, and hand the answer +/// back untouched. `req.url` is whatever followed `/api/blockfrost`, so +/// `/addresses/addr_test1.../utxos` arrives here exactly as Mesh asked for it. +export async function handleBlockfrost(req: IncomingMessage, res: ServerResponse): Promise { + if (!KEY) { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "BLOCKFROST_API_KEY is not set in .env" })); + return; + } + + // Buffer the body rather than parse it: the evaluate endpoint sends JSON and + // the submit endpoint sends raw CBOR, and a Buffer carries both unharmed. + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined; + + const contentType = req.headers["content-type"]; + + try { + // #region forward + const upstream = await fetch(`${BLOCKFROST}${req.url ?? "/"}`, { + method: req.method, + headers: { + project_id: KEY, + ...(contentType ? { "content-type": contentType } : {}), + }, + body, + }); + // #endregion forward + + res.statusCode = upstream.status; + res.setHeader("content-type", upstream.headers.get("content-type") ?? "application/json"); + res.end(Buffer.from(await upstream.arrayBuffer())); + } catch (error) { + res.statusCode = 502; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: `could not reach Blockfrost: ${(error as Error).message}` })); + } +} +// #endregion handler + +// #region plugin +/// Mount the handler at `/api/blockfrost` on Vite's server, so `npm run dev` +/// starts the front and the back together. +/// +/// In production you run `handleBlockfrost` in a server of your own, it is a +/// plain Node request handler and knows nothing about Vite. +export function blockfrostProxy(): Plugin { + return { + name: "blockfrost-proxy", + configureServer(server) { + server.middlewares.use("/api/blockfrost", handleBlockfrost); + }, + configurePreviewServer(server) { + server.middlewares.use("/api/blockfrost", handleBlockfrost); + }, + }; +} +// #endregion plugin +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx new file mode 100644 index 0000000000..d8281c3af0 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -0,0 +1,96 @@ +// Playground bookkeeping, kept out of the lecture: this file is what lecture 9 +// renders, and it runs here too, served at **/vault.html**. `main.tsx` is the +// same idea with the recovery door and some styling. Both drive the same +// `./lib`, which is the code the reader writes. +// #region file +/// The page: connect a wallet, lock 5 ADA, mint the vault's own token, and +/// unlock again. Every button below builds a transaction with the files in +/// `./lib`, then hands it to the wallet to sign and submit. +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { BlockfrostProvider, BrowserWallet, deserializeAddress } from "@meshsdk/core"; +import type { UTxO } from "@meshsdk/core"; + +import { vaultAddress } from "./lib/blueprint.ts"; +import { buildLockTx } from "./lib/lock.ts"; +import { buildMintAndLockTx } from "./lib/mint.ts"; +import { buildUnlockTx } from "./lib/unlock.ts"; +import { fetchLocked } from "./lib/fetch.ts"; + +const NETWORK_ID = Number(import.meta.env.VITE_NETWORK_ID ?? "0"); + +// No key here. The provider points at our own backend, which holds it, see +// `server/blockfrost.ts`. Mesh supports this: give it a path instead of a +// project id and it treats it as a privately hosted Blockfrost. +const provider = new BlockfrostProvider("/api/blockfrost"); + +function App() { + const [wallet, setWallet] = useState(); + const [owner, setOwner] = useState(""); + const [locked, setLocked] = useState([]); + const [status, setStatus] = useState(""); + + async function connect() { + const connected = await BrowserWallet.enable("lace"); + setWallet(connected); + // Your key hash. The vault's address is shared with everyone who compiled + // the same contract, so this is what picks out the UTxOs that are yours. + const pubKeyHash = deserializeAddress(await connected.getChangeAddress()).pubKeyHash; + setOwner(pubKeyHash); + setLocked(await fetchLocked(provider, NETWORK_ID, pubKeyHash)); + } + + // Build, sign, submit. The `true` is a **partial** signature: the wallet signs + // its own inputs and leaves the script input alone, because no key can sign + // for a script, the validator decides that one when the network runs it. + async function run(build: () => Promise) { + setStatus("Approve the transaction in your wallet…"); + try { + const unsignedTx = await build(); + const signedTx = await wallet!.signTx(unsignedTx, true); + const hash = await wallet!.submitTx(signedTx); + setStatus(`submitted: ${hash}`); + } catch (error) { + setStatus(`error: ${(error as Error).message}`); + } + } + + function lock() { + run(() => buildLockTx(wallet!, provider, NETWORK_ID, "5000000")); + } + + // The same lock, plus one token minted under the vault's own policy. One + // transaction, two purposes of one script, from **validator purposes**. + function mintAndLock() { + run(() => buildMintAndLockTx(wallet!, provider, NETWORK_ID, "5000000")); + } + + function unlock(utxo: UTxO) { + run(() => buildUnlockTx(wallet!, provider, utxo, provider)); + } + + if (!wallet) return ; + + return ( +
+

The vault lives at {vaultAddress(NETWORK_ID)}

+ + + +
    + {locked.map((utxo) => ( +
  • + {utxo.input.txHash.slice(0, 8)}… + +
  • + ))} +
+

{status}

+
+ ); +} + +createRoot(document.getElementById("root")!).render(); +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts new file mode 100644 index 0000000000..bcb842f570 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts @@ -0,0 +1,33 @@ +// The first off-chain script in the track, and the only one that is not part of +// the app: it opens **frontend integration** by proving the provider works +// before anything is built on top of it. It talks to the chain and nothing +// else: no contract, no wallet, no transaction. Run it with `node src/check.ts` +// on Node 22.6 or newer. +// +// Its two values come from `.env`, so no key is ever written into the code. +// #region check +import { BlockfrostProvider } from "@meshsdk/core"; + +// Read `.env` into process.env. Node does this natively, no library needed. +// It looks in the folder you run from, so run this from your workspace root. +try { + process.loadEnvFile(); +} catch { + throw new Error("no .env here. Run this from your workspace root: node off-chain/src/check.ts"); +} + +// This script runs on your machine, so it may hold the key. The browser app +// built later in that lecture may not, which is why the name has no VITE_ prefix. +const provider = new BlockfrostProvider(process.env.BLOCKFROST_API_KEY ?? ""); +const address = process.env.MY_ADDRESS ?? ""; + +try { + const params = await provider.fetchProtocolParameters(); + console.log("connected. current epoch:", params.epoch); + + const utxos = await provider.fetchAddressUTxOs(address); + console.log(`${utxos.length} UTxOs at this address`); +} catch (error) { + console.error("the provider refused:", JSON.parse(String(error)).data?.message ?? error); +} +// #endregion check diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/index.css b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/index.css new file mode 100644 index 0000000000..f1d8c73cdc --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts new file mode 100644 index 0000000000..5194b6cb0e --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts @@ -0,0 +1,52 @@ +// The import below uses this project's own layout, where the blueprint sits in +// `blueprints/`. The reader's sits in `on-chain/vault/`, so the `#replace` +// directive renders their path in the docs while this file keeps the one it +// needs to run. Both this note and the directive stay out of the page. +// #region file +import { applyParamsToScript, resolveScriptHash, serializePlutusScript } from "@meshsdk/core"; + +// The blueprint your `aiken build` wrote, and the only file that names its +// path. The title is `..`, and your validator is in +// `vault.ak`. +// #replace ../../blueprints/vault.plutus.json -> ../../../on-chain/vault/plutus.json +import blueprint from "../../blueprints/vault.plutus.json" with { type: "json" }; + +export { blueprint }; + +// #region recovery-const +// The recovery key your vault was compiled around: the parameter from the +// parameters lecture, filling the blank the compiler left. Any key hash works, +// but it fixes the address, so it has to be the same every time. +const RECOVERY = "00000000000000000000000000000000000000000000000000000000"; +// #endregion recovery-const + +const PLUTUS_VERSION = "V3"; + +type Blueprint = { validators: { title: string; compiledCode: string }[] }; + +function compiledCode(source: Blueprint, title: string): string { + const validator = source.validators.find((v) => v.title === title); + if (!validator) throw new Error(`validator "${title}" not found in the blueprint`); + return validator.compiledCode; +} + +/// The compiled contract, with the recovery key built into it. +// #region params +export const vaultScriptCbor = applyParamsToScript(compiledCode(blueprint, "vault.vault.spend"), [RECOVERY]); +// #endregion params + +/// The script's address: the hash of that script, written for one network. +/// Anything sent here can only be spent if the validator says yes. +export function vaultAddress(networkId: number): string { + return serializePlutusScript( + { code: vaultScriptCbor, version: PLUTUS_VERSION }, + undefined, + networkId, + ).address; +} + +/// The same hash, read as a **policy id**. +export function vaultPolicyId(): string { + return resolveScriptHash(vaultScriptCbor, PLUTUS_VERSION); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts new file mode 100644 index 0000000000..12589827b9 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts @@ -0,0 +1,21 @@ +// #region file +import { mConStr0, mConStr1 } from "@meshsdk/core"; +import type { Data } from "@meshsdk/core"; + +/// The **datum**: who owns the locked UTxO, mirroring the on-chain +/// `VaultDatum { owner }`. +export function vaultDatum(ownerPubKeyHash: string): Data { + return mConStr0([ownerPubKeyHash]); +} + +/// The **redeemer**: the spender's action, mirroring `VaultAction`. `Unlock` is +/// the first constructor, so it is number 0, and it carries no fields. +export const unlockRedeemer: Data = mConStr0([]); + +// #region recover +/// The second action, added in **parameters**. `Recover` is declared after +/// `Unlock` on-chain, so it is constructor **1**, the number is the whole +/// difference between the two. +export const recoverRedeemer: Data = mConStr1([]); +// #endregion recover +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts new file mode 100644 index 0000000000..9a68831239 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts @@ -0,0 +1,35 @@ +// #region file +import { deserializeDatum } from "@meshsdk/core"; +import type { IFetcher, UTxO } from "@meshsdk/core"; + +import { vaultAddress } from "./blueprint.ts"; + +/// Read the owner back out of a locked UTxO's datum. Returns `undefined` if the +/// UTxO has no datum, or carries one this contract cannot read, anyone may send +/// funds to a script address, including by mistake. +function ownerOf(utxo: UTxO): string | undefined { + try { + return String(deserializeDatum(utxo.output.plutusData ?? "").fields[0].bytes); + } catch { + return undefined; + } +} + +/// Read the UTxOs locked at the contract that name **you** as the owner. +/// +/// The filter is the part worth understanding. A script address is not yours: it +/// is the hash of the contract, so everyone who compiles this same contract gets +/// the same address, and everything they lock sits here alongside yours. Fetching +/// without filtering returns their UTxOs too, and the validator will refuse when +/// you try to spend one, because its datum names someone else. +/// +/// What makes a UTxO yours is the datum, not the address. +export async function fetchLocked( + provider: IFetcher, + networkId: number, + ownerPubKeyHash: string, +): Promise { + const all = await provider.fetchAddressUTxOs(vaultAddress(networkId)); + return all.filter((utxo) => ownerOf(utxo) === ownerPubKeyHash); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts new file mode 100644 index 0000000000..23ea1b7128 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts @@ -0,0 +1,38 @@ +// #region file +import { MeshTxBuilder, deserializeAddress } from "@meshsdk/core"; +import type { IFetcher, IWallet } from "@meshsdk/core"; + +import { vaultAddress } from "./blueprint.ts"; +import { vaultDatum } from "./datum.ts"; + +/// Build a transaction that **locks** `lovelace` at the contract's address, with +/// a datum naming the connected wallet as the **owner**, the only one who'll be +/// able to unlock it. This is an ordinary payment to a script address; the +/// contract doesn't run yet, it only runs later, when someone tries to unlock. +export async function buildLockTx( + wallet: IWallet, + provider: IFetcher, + networkId: number, + lovelace: string, +): Promise { + // Where the wallet wants anything left over sent back to. + const changeAddress = await wallet.getChangeAddress(); + // The key hash inside that address. This is what makes you the owner. + const owner = deserializeAddress(changeAddress).pubKeyHash; + + // The builder. `fetcher` is how it looks up UTxOs and protocol parameters. + const txBuilder = new MeshTxBuilder({ fetcher: provider }); + return await txBuilder + // Create an output at the contract's address, holding the funds. + .txOut(vaultAddress(networkId), [{ unit: "lovelace", quantity: lovelace }]) + // Attach the note that names you. `Inline` means it is stored on the UTxO + // itself, in full, rather than as a hash the spender has to supply later. + .txOutInlineDatumValue(vaultDatum(owner)) + // Send the remainder back to you. + .changeAddress(changeAddress) + // Offer your UTxOs, so the builder can pick enough to cover this. + .selectUtxosFrom(await wallet.getUtxos()) + // Balance it, price the fee, and hand back the unsigned transaction. + .complete(); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts new file mode 100644 index 0000000000..957a0057bd --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts @@ -0,0 +1,76 @@ +// #region file +import { MeshTxBuilder, deserializeAddress, mConStr0, stringToHex } from "@meshsdk/core"; +import type { IFetcher, IWallet } from "@meshsdk/core"; + +import { vaultAddress, vaultPolicyId, vaultScriptCbor } from "./blueprint.ts"; +import { vaultDatum } from "./datum.ts"; + +/// The token name the vault's `mint` handler allows, as the contract spells it. +export const VAULT_TOKEN_NAME = "VAULT"; + +/// Build a transaction that **mints one vault token and locks it**, together with +/// `lovelace`, at the vault's address. +/// +/// One script does both jobs here, because its hash is both the address funds go +/// to and the policy id the token is created under. The `mint` handler runs to +/// approve the new token; the `spend` handler stays asleep, since locking is +/// still an ordinary payment. +export async function buildMintAndLockTx( + wallet: IWallet, + provider: IFetcher, + networkId: number, + lovelace: string, +): Promise { + // Where the wallet wants anything left over sent back to. + const changeAddress = await wallet.getChangeAddress(); + // The key hash inside that address. This is what makes you the owner. + const owner = deserializeAddress(changeAddress).pubKeyHash; + // Minting runs a script, so this transaction needs a deposit, unlike the + // plain lock, where no contract runs at all. + const collateral = (await wallet.getCollateral())[0]; + if (!collateral) { + throw new Error( + "no collateral: this wallet needs a UTxO holding at least 5 ADA and no tokens. " + + "Send it some test ADA and try again.", + ); + } + + // The contract's hash, read as a policy id. Same value as its address. + const policyId = vaultPolicyId(); + // Token names travel as hex on the chain, so convert it once here. + const tokenNameHex = stringToHex(VAULT_TOKEN_NAME); + + const txBuilder = new MeshTxBuilder({ fetcher: provider }); + return await txBuilder +// #region mint-calls + // Everything that follows describes one Plutus V3 script minting. + .mintPlutusScriptV3() + // Create exactly one token, which is precisely what the handler allows. + .mint("1", policyId, tokenNameHex) + // Carry the compiled contract, so the network can run the mint handler. + .mintingScript(vaultScriptCbor) + // The mint handler ignores its redeemer, so an empty one is enough. + .mintRedeemerValue(mConStr0([])) + // #endregion mint-calls + // One output at the vault, holding both the ADA and the new token. + .txOut(vaultAddress(networkId), [ + { unit: "lovelace", quantity: lovelace }, + { unit: policyId + tokenNameHex, quantity: "1" }, + ]) + // The same datum as a plain lock: the token changes nothing about ownership. + .txOutInlineDatumValue(vaultDatum(owner)) + // Offer the deposit found above. + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + // Send the remainder back to you. + .changeAddress(changeAddress) + // Offer your UTxOs, so the builder can pick enough to cover this. + .selectUtxosFrom(await wallet.getUtxos()) + // Balance it, price the fee, and hand back the unsigned transaction. + .complete(); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts new file mode 100644 index 0000000000..2c5896fe0d --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts @@ -0,0 +1,74 @@ +// #region file +import { MeshTxBuilder, deserializeAddress } from "@meshsdk/core"; +import type { IEvaluator, IFetcher, IWallet, UTxO } from "@meshsdk/core"; + +import { vaultScriptCbor } from "./blueprint.ts"; +import { recoverRedeemer } from "./datum.ts"; + +/// Take the funds out through the **backup key** instead of the owner's. +/// +/// Compare this with `unlock.ts` and only one line differs: the redeemer says +/// `Recover` rather than `Unlock`. Everything else is the same, because the two +/// paths spend the same UTxO at the same address, carrying the same script. +/// +/// What changes is which signature the validator then looks for. `Unlock` checks +/// the datum's owner; `Recover` checks the key built into the script. So this +/// must be signed by the recovery wallet, not the owner's. +export async function buildRecoverTx( + wallet: IWallet, + provider: IFetcher, + lockedUtxo: UTxO, + evaluator?: IEvaluator, +): Promise { + // Where the wallet wants anything left over sent back to. + const changeAddress = await wallet.getChangeAddress(); + // The key hash inside it. Note this is the *backup* wallet's, not the owner's + // whichever wallet you handed in is the one whose signature this asks for. + const recovery = deserializeAddress(changeAddress).pubKeyHash; + // The deposit, the same as any other spend that runs a script. + const collateral = (await wallet.getCollateral())[0]; + if (!collateral) { + throw new Error( + "no collateral: this wallet needs a UTxO holding at least 5 ADA and no tokens. " + + "Send it some test ADA and try again.", + ); + } + + // Passing `evaluator` is what makes the contract run here, before you send. + const txBuilder = new MeshTxBuilder({ fetcher: provider, evaluator }); + return await txBuilder + // Everything that follows describes one Plutus V3 script being spent. + .spendingPlutusScriptV3() + // The same locked UTxO the owner would have spent. + .txIn( + lockedUtxo.input.txHash, + lockedUtxo.input.outputIndex, + lockedUtxo.output.amount, + lockedUtxo.output.address, + ) + // The same compiled contract, too. + .txInScript(vaultScriptCbor) + // The datum is already on the UTxO, so there is nothing to attach here. + .txInInlineDatumPresent() +// #region recover-redeemer + // **The one line that differs from `unlock.ts`**: `Recover`, not `Unlock`. + // This is what tells the validator which of its two branches to take. + .txInRedeemerValue(recoverRedeemer) + // And so the signature it looks for is the recovery key's. + .requiredSignerHash(recovery) + // #endregion recover-redeemer + // Offer the deposit found above. + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + // Send the remainder back to you. + .changeAddress(changeAddress) + // Offer your UTxOs, so the builder can pick enough to cover the fee. + .selectUtxosFrom(await wallet.getUtxos()) + // Balance it, price the fee, and hand back the unsigned transaction. + .complete(); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts new file mode 100644 index 0000000000..242df2321b --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts @@ -0,0 +1,77 @@ +// #region file +import { MeshTxBuilder, deserializeAddress } from "@meshsdk/core"; +import type { IEvaluator, IFetcher, IWallet, UTxO } from "@meshsdk/core"; + +import { vaultScriptCbor } from "./blueprint.ts"; +import { unlockRedeemer } from "./datum.ts"; + +/// Build a transaction that **unlocks** `lockedUtxo`. This is where the contract +/// runs: the network hands the validator the datum (the owner), our redeemer, and +/// the transaction, and only lets the spend through if the owner signed it. +/// +/// We declare the owner as a **required signer** so the wallet's signature is in +/// the transaction for the validator to check. Spending a script UTxO also needs +/// the **script**, the **redeemer**, and a **collateral** UTxO (a deposit the +/// network keeps only if the script unexpectedly fails). +/// +/// Passing an `evaluator` makes the builder **run the validator before it returns**, +/// so a spend the contract would refuse fails here rather than on the chain. Without +/// one the builder just guesses a cost budget and the refusal happens on-chain, which +/// is what costs you the collateral. +export async function buildUnlockTx( + wallet: IWallet, + provider: IFetcher, + lockedUtxo: UTxO, + evaluator?: IEvaluator, +): Promise { + // Where the wallet wants anything left over sent back to. + const changeAddress = await wallet.getChangeAddress(); + // The key hash inside it, the same one the datum recorded when you locked. + const owner = deserializeAddress(changeAddress).pubKeyHash; + // The deposit. Any UTxO of yours will do; the network only takes it if the + // script fails in a way the pre-flight did not predict. + const collateral = (await wallet.getCollateral())[0]; + if (!collateral) { + throw new Error( + "no collateral: this wallet needs a UTxO holding at least 5 ADA and no tokens. " + + "Send it some test ADA and try again.", + ); + } + + // Passing `evaluator` is what makes the contract run here, before you send. + const txBuilder = new MeshTxBuilder({ fetcher: provider, evaluator }); + return await txBuilder + // Everything that follows describes one Plutus V3 script being spent. + .spendingPlutusScriptV3() + // The locked UTxO to spend: which transaction made it, which output it was, + // what it holds, and the address it sits at. + .txIn( + lockedUtxo.input.txHash, + lockedUtxo.input.outputIndex, + lockedUtxo.output.amount, + lockedUtxo.output.address, + ) + // Carry the compiled contract, so the network has the code to run. + .txInScript(vaultScriptCbor) + // The datum is already on the UTxO, so there is nothing to attach here. + .txInInlineDatumPresent() + // The action you are asking for: `Unlock`. + .txInRedeemerValue(unlockRedeemer) + // Put your key hash in `extra_signatories`, exactly the list the rule reads. + // Leave this out and a correct contract refuses a legitimate spend. + .requiredSignerHash(owner) + // Offer the deposit found above. + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + // Send the remainder back to you. + .changeAddress(changeAddress) + // Offer your UTxOs, so the builder can pick enough to cover the fee. + .selectUtxosFrom(await wallet.getUtxos()) + // Balance it, price the fee, and hand back the unsigned transaction. + .complete(); +} +// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx new file mode 100644 index 0000000000..ef71119795 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { BlockfrostProvider, BrowserWallet, deserializeAddress } from "@meshsdk/core"; +import type { UTxO } from "@meshsdk/core"; + +import { vaultAddress } from "./lib/blueprint.ts"; +import { buildLockTx } from "./lib/lock.ts"; +import { buildMintAndLockTx } from "./lib/mint.ts"; +import { buildUnlockTx } from "./lib/unlock.ts"; +import { fetchLocked } from "./lib/fetch.ts"; +import "./index.css"; + +const NETWORK_ID = Number(import.meta.env.VITE_NETWORK_ID ?? "0"); + +// No key here. The provider points at our own backend, which holds it, see +// `server/blockfrost.ts`. +const provider = new BlockfrostProvider("/api/blockfrost"); +const EXPLORER = "https://explorer.cardano.org/preview/transaction?id="; + +// The contract's own address, derived from the compiled validator. It belongs to +// no one: only a transaction the validator approves can spend what sits here. +const VAULT_ADDRESS = vaultAddress(NETWORK_ID); + +/** Lovelace held by a UTxO, as a readable ADA string. */ +function ada(utxo: UTxO): string { + const lovelace = utxo.output.amount.find((a) => a.unit === "lovelace")?.quantity ?? "0"; + return (Number(lovelace) / 1_000_000).toFixed(2) + " ADA"; +} + +/** A numbered step card. */ +function Step(props: { n: number; title: string; hint: ReactNode; children: ReactNode }) { + return ( +
+ + {props.n} + +
+

{props.title}

+

{props.hint}

+ {props.children} +
+
+ ); +} + +const btn = + "rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-40"; + +function App() { + const [wallet, setWallet] = useState(); + const [address, setAddress] = useState(""); + const [owner, setOwner] = useState(""); + const [hasCollateral, setHasCollateral] = useState(false); + const [locked, setLocked] = useState([]); + const [status, setStatus] = useState(""); + const [txHash, setTxHash] = useState(""); + + const laceInstalled = BrowserWallet.getInstalledWallets().some((w) => w.id === "lace"); + + async function connect() { + try { + const connected = await BrowserWallet.enable("lace"); + setWallet(connected); + const changeAddress = await connected.getChangeAddress(); + setAddress(changeAddress); + // Your key hash. The vault's address is shared with everyone who compiled + // the same contract, so this is what picks out the UTxOs that are yours. + const pubKeyHash = deserializeAddress(changeAddress).pubKeyHash; + setOwner(pubKeyHash); + setHasCollateral((await connected.getCollateral()).length > 0); + setLocked(await fetchLocked(provider, NETWORK_ID, pubKeyHash)); + setStatus(""); + } catch (error) { + setStatus(`error: ${(error as Error).message}`); + } + } + + async function checkCollateral() { + if (wallet) setHasCollateral((await wallet.getCollateral()).length > 0); + } + + async function reloadLocked() { + setLocked(await fetchLocked(provider, NETWORK_ID, owner)); + } + + // Build → sign (partial, so the wallet signs its own inputs and leaves the + // script input to the network) → submit. Returns the transaction hash. + function run(action: () => Promise) { + setTxHash(""); + setStatus("Working… approve the transaction in your wallet."); + action() + .then(async (unsignedTx) => { + const signedTx = await wallet!.signTx(unsignedTx, true); + const hash = await wallet!.submitTx(signedTx); + setTxHash(hash); + setStatus("Submitted. Give it a moment to confirm, then Refresh."); + }) + .catch((error) => setStatus(`error: ${(error as Error).message}`)); + } + + return ( +
+

Lock & unlock a smart contract

+

+ Lock some test ADA in a vault, then unlock it. The contract only releases the funds to the + owner named in the datum, proven by a signature. The datum is public, but a signature + can't be forged, so only you (the locker) can take it back. +

+ +

+ The vault's address{" "} + + (paste it into the explorer to see everything locked here) + +
+ {VAULT_ADDRESS} +

+ + + {!laceInstalled ? ( +

Lace not found. Install it and switch to Preview.

+ ) : wallet ? ( +

Connected: {address}

+ ) : ( + + )} +
+ + + {" "} + {hasCollateral ? "✓ set" : "not set"} + + + + {" "} + +

+ The second button also mints one VAULT token and locks it with the ADA. Same script + hash, two jobs: the address the funds go to, and the policy id the token is created under. + Unlocking brings both back. +

+
+ + + +
    + {locked.length === 0 ? ( +
  • Nothing locked by you yet.
  • + ) : ( + locked.map((utxo) => ( +
  • + + {utxo.input.txHash.slice(0, 8)}…#{utxo.input.outputIndex} + + {ada(utxo)} + +
  • + )) + )} +
+
+ + {status &&

{status}

} + {txHash && ( +

+ + view on explorer + +

+ )} +
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts new file mode 100644 index 0000000000..81fcdbc6d1 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts @@ -0,0 +1,202 @@ +// Everything the unlock scenario test needs. The reader writes this file in +// **testing**, so this block is the one they type first. +// #region offline-imports +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DEFAULT_PROTOCOL_PARAMETERS, + OfflineFetcher, + deserializeAddress, + serializeData, +} from "@meshsdk/core"; +import type { Asset } from "@meshsdk/core"; +import { OfflineEvaluator } from "@meshsdk/core-csl"; +import { MeshWallet } from "@meshsdk/wallet"; + +import { blueprint, vaultAddress } from "./lib/blueprint.ts"; +import { vaultDatum } from "./lib/datum.ts"; +import { buildLockTx } from "./lib/lock.ts"; +import { buildUnlockTx } from "./lib/unlock.ts"; +// #endregion offline-imports + +// The other tests in this file need more: the lock, mint and recover builders, +// and two helpers for applying a parameter by hand. The blueprint itself comes +// from `lib/blueprint.ts`, which is where its path is written down once. +import { applyParamsToScript, serializePlutusScript } from "@meshsdk/core"; + +import { buildMintAndLockTx } from "./lib/mint.ts"; +import { buildRecoverTx } from "./lib/recover.ts"; + +// An in-memory chain and a funded wallet. No node, no network, no test ADA, and +// no waiting: every test below builds a real transaction and runs the real +// compiled validator against it. +// #region offline-setup +const NETWORK = 0; + +const OWNER = + "system envelope wine dune joy cage senior predict lift lunch foam bring shoe permit boss balcony inherit fold cat again stone topic truly all".split( + " ", + ); + +function newFetcher(): OfflineFetcher { + const fetcher = new OfflineFetcher("preview"); + fetcher.addProtocolParameters(DEFAULT_PROTOCOL_PARAMETERS); + return fetcher; +} + +async function makeWallet(fetcher: OfflineFetcher, mnemonic: string[]): Promise { + // No submitter: these tests build and evaluate, they never submit anywhere. + const wallet = new MeshWallet({ + networkId: NETWORK, + fetcher, + key: { type: "mnemonic", words: mnemonic }, + }); + await wallet.init(); + return wallet; +} +// #endregion offline-setup + +// Putting a UTxO on the pretend chain. A real chain hands you a transaction +// hash; here we invent one, because nothing was ever submitted. +// #region offline-helpers +let txCounter = 0; +function nextTxHash(): string { + txCounter += 1; + return txCounter.toString(16).padStart(64, "0"); +} + +function addUtxo(fetcher: OfflineFetcher, address: string, assets: Asset[], plutusData?: string) { + const utxo = { + input: { txHash: nextTxHash(), outputIndex: 0 }, + output: { address, amount: assets, ...(plutusData ? { plutusData } : {}) }, + }; + fetcher.addUTxOs([utxo]); + return utxo; +} + +/// A big ADA UTxO for fees and change, plus a 5 ADA one that serves as collateral. +function fund(fetcher: OfflineFetcher, address: string) { + addUtxo(fetcher, address, [{ unit: "lovelace", quantity: "1000000000" }]); + addUtxo(fetcher, address, [{ unit: "lovelace", quantity: "5000000" }]); +} + +const FIVE_ADA: Asset[] = [{ unit: "lovelace", quantity: "5000000" }]; +// #endregion offline-helpers + +/// The vault built around an arbitrary recovery key, rather than the one fixed +/// in `lib/blueprint.ts`. The reader changes that constant by hand; this lets +/// the parameters test show two keys giving two addresses in a single run. +function recoveryVaultAddress(recoveryPubKeyHash: string, networkId: number): string { + const validator = blueprint.validators.find((v) => v.title === "vault.vault.spend"); + if (!validator) throw new Error('validator "vault.vault.spend" not found in the blueprint'); + const cbor = applyParamsToScript(validator.compiledCode, [recoveryPubKeyHash]); + return serializePlutusScript({ code: cbor, version: "V3" }, undefined, networkId).address; +} + +// #region offline-lock +test("lock: the vault's lock transaction is an ordinary payment carrying a datum", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + fund(fetcher, address); + + const unsignedTx = await buildLockTx(owner, fetcher, NETWORK, "5000000"); + assert.ok(unsignedTx.length > 0, "lock transaction should build"); +}); +// #endregion offline-lock + +test("mint: one transaction mints a vault token and locks it", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + fund(fetcher, address); + + const unsignedTx = await buildMintAndLockTx(owner, fetcher, NETWORK, "5000000"); + + // Evaluating runs the compiled mint handler. A cost budget back means the + // script approved the new token. + const evaluator = new OfflineEvaluator(fetcher, "preview"); + const costs = await evaluator.evaluateTx(unsignedTx, [], []); + assert.ok(costs.length >= 1, "the mint handler should approve one token"); +}); + +// #region offline-unlock +test("unlock: the vault releases funds to the owner who signs", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + const pubKeyHash = deserializeAddress(address).pubKeyHash; + fund(fetcher, address); + + // Put a locked UTxO on our pretend chain, with the owner named in its datum. + const locked = addUtxo( + fetcher, + vaultAddress(NETWORK), + FIVE_ADA, + serializeData(vaultDatum(pubKeyHash)), + ); + + const unsignedTx = await buildUnlockTx(owner, fetcher, locked); + + // Evaluating runs the actual compiled validator. Getting a cost budget back + // means it said yes. + const evaluator = new OfflineEvaluator(fetcher, "preview"); + const costs = await evaluator.evaluateTx(unsignedTx, [], []); + assert.ok(costs.length >= 1, "the validator should approve the spend"); +}); +// #endregion offline-unlock + +// The recovery door, proven offline. `lib/blueprint.ts` compiles the vault +// around a fixed `RECOVERY` constant, and no wallet's key hash is ever going to +// equal it, so what this can show is the half that matters: `Recover` checks the +// key welded into the script and ignores the datum's owner entirely. The owner +// signing a `Recover` spend is refused, which is exactly what keeps the two +// doors separate. +test("recover: the owner's signature does not open the recovery door", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + const pubKeyHash = deserializeAddress(address).pubKeyHash; + fund(fetcher, address); + + const locked = addUtxo( + fetcher, + vaultAddress(NETWORK), + FIVE_ADA, + serializeData(vaultDatum(pubKeyHash)), + ); + + // The same UTxO the unlock test spends, and the same wallet signing it. Only + // the redeemer differs, so only the branch the validator takes differs. + const unsignedTx = await buildRecoverTx(owner, fetcher, locked); + + const evaluator = new OfflineEvaluator(fetcher, "preview"); + await assert.rejects( + () => evaluator.evaluateTx(unsignedTx, [], []), + "the validator should refuse a Recover signed by the owner", + ); +}); + +test("parameters: a different recovery key gives the vault a different address", () => { + // Two 28-byte key hashes, written as hex. + const alice = "a".repeat(56); + const bob = "b".repeat(56); + + const alicesVault = recoveryVaultAddress(alice, NETWORK); + const bobsVault = recoveryVaultAddress(bob, NETWORK); + + // Same source code, same compiled validator, two addresses. The recovery key + // is part of the script, the script's hash is the address, so changing the + // key moves the vault. + assert.notEqual(alicesVault, bobsVault, "each recovery key should get its own address"); + + // Both are real addresses, which also proves the parameter was applied to a + // script the ledger can read rather than producing nonsense. + for (const address of [alicesVault, bobsVault]) { + assert.match(address, /^addr_test1/, "should be a valid Preview script address"); + } + + // And it is stable: the same key always lands on the same vault. + assert.equal(recoveryVaultAddress(alice, NETWORK), alicesVault); +}); diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json new file mode 100644 index 0000000000..5d94abc30c --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node", "vite/client"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vault.html b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vault.html new file mode 100644 index 0000000000..8ef204d91f --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vault.html @@ -0,0 +1,11 @@ + + + + + My vault + + +
+ + + diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts new file mode 100644 index 0000000000..ed51ef3e35 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts @@ -0,0 +1,32 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import { blockfrostProxy } from "./server/blockfrost.ts"; + +// Mesh uses Node built-ins (Buffer, crypto, stream) in the browser, so we polyfill them. +// `blockfrostProxy` is the backend: it serves /api/blockfrost, and it is the only +// thing here that reads the key. +// +// Two pages, both driving the same `src/lib`: +// index.html -> src/main.tsx the styled vault, with the minting button +// vault.html -> src/app.tsx the page the reader builds in lecture 9 +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), + blockfrostProxy(), + ], + server: { allowedHosts: true }, + build: { + target: "esnext", + rollupOptions: { + input: { + main: resolve(import.meta.dirname, "index.html"), + vault: resolve(import.meta.dirname, "vault.html"), + }, + }, + }, +}); diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock new file mode 100644 index 0000000000..9628fe316c --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock @@ -0,0 +1,37 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +[[requirements]] +name = "aiken-lang/stdlib" +version = "v3.1.0" +source = "github" + +[[requirements]] +name = "sidan-lab/vodka" +version = "0.1.23" +source = "github" + +[[requirements]] +name = "aiken-lang/fuzz" +version = "v2.2.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "v3.1.0" +requirements = [] +source = "github" + +[[packages]] +name = "sidan-lab/vodka" +version = "0.1.23" +requirements = [] +source = "github" + +[[packages]] +name = "aiken-lang/fuzz" +version = "v2.2.0" +requirements = [] +source = "github" + +[etags] diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml new file mode 100644 index 0000000000..19428d296d --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml @@ -0,0 +1,28 @@ +name = "cardano-foundation/onboarding-vault" +version = "0.0.0" +compiler = "v1.1.23" +plutus = "v3" +license = "Apache-2.0" +description = "The vault built across the onboarding Intermediate lectures" + +[repository] +user = "cardano-foundation" +project = "developer-portal" +platform = "github" + +[[dependencies]] +name = "aiken-lang/stdlib" +version = "v3.1.0" +source = "github" + +[[dependencies]] +name = "sidan-lab/vodka" +version = "0.1.23" +source = "github" + +[[dependencies]] +name = "aiken-lang/fuzz" +version = "v2.2.0" +source = "github" + +[config] diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json new file mode 100644 index 0000000000..c092dcfdf7 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json @@ -0,0 +1,170 @@ +{ + "preamble": { + "title": "cardano-foundation/onboarding-vault", + "description": "The vault built across the onboarding Intermediate lectures", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.23+unknown" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "vault.vault.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/vault~1VaultDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/vault~1VaultAction" + } + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault.vault.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault.vault.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "recovery", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", + "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + }, + { + "title": "vault_simple.vault.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/vault_simple~1VaultDatum" + } + }, + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/vault_simple~1VaultAction" + } + }, + "compiledCode": "58d801010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea80162b3001300a375400b15980099b8748000c024dd5000c4c8cc88cc008008004896600200314a115980099b8f375c601e00200714a31330020023010001402c8070dd618069807180718071807180718071807180718059baa300d008375c601860146ea8c030c028dd5000c5900845900b45900818050009805180580098039baa0018b200a30070013007300800130070013003375400f149a26cac80081", + "hash": "ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570" + }, + { + "title": "vault_simple.vault.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "58d801010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea80162b3001300a375400b15980099b8748000c024dd5000c4c8cc88cc008008004896600200314a115980099b8f375c601e00200714a31330020023010001402c8070dd618069807180718071807180718071807180718059baa300d008375c601860146ea8c030c028dd5000c5900845900b45900818050009805180580098039baa0018b200a30070013007300800130070013003375400f149a26cac80081", + "hash": "ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570" + } + ], + "definitions": { + "Data": { + "title": "Data", + "description": "Any Plutus data." + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "vault/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Recover", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "vault/VaultDatum": { + "title": "VaultDatum", + "anyOf": [ + { + "title": "VaultDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "owner", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + }, + "vault_simple/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + } + ] + }, + "vault_simple/VaultDatum": { + "title": "VaultDatum", + "anyOf": [ + { + "title": "VaultDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "owner", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak new file mode 100644 index 0000000000..3985ac84dc --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak @@ -0,0 +1,172 @@ +use aiken/collection/list +use aiken/fuzz +use aiken/crypto.{VerificationKeyHash} + +// #region import-policy-id +use cardano/assets.{PolicyId} +// #endregion import-policy-id + +use cardano/transaction.{OutputReference, Transaction} + +// #region import-token-minted +use cocktail.{token_minted} +// #endregion import-token-minted + +// #region spend-test-imports +use mocktail.{complete, mocktail_tx, required_signer_hash} +use mocktail/virgin_key_hash.{mock_pub_key_hash} +use mocktail/virgin_output_reference.{mock_utxo_ref} +// #endregion spend-test-imports + +// The mint tests need two more helpers. They live in modules already imported +// above, and Aiken takes them on their own lines, so the reader adds these +// rather than editing the lines they already have. +// #region mint-test-imports +use mocktail.{mint} +use mocktail/virgin_key_hash.{mock_script_hash} +// #endregion mint-test-imports + +// The datum: who owns *this* locked UTxO. It changes from one UTxO to the next, +// so it belongs on the UTxO. +// #region types +pub type VaultDatum { + owner: VerificationKeyHash, +} +// #endregion types + +// The redeemer: which of the two actions the spender is taking. +// #region types +pub type VaultAction { + Unlock + Recover +} + +// #endregion types + +// The name of the token this vault can mint. One script, one token. +// #region token-name +const vault_token: ByteArray = "VAULT" +// #endregion token-name + +// The vault the reader builds across the track: the owner's key, a backup key +// welded in at build time, and a token of its own. +// +// `spend` guards the locked UTxO. Three facts reach it, each fixed at a +// different moment: `recovery` at build time, `owner` at lock time, and the +// action at spend time. +// +// `mint` guards the token: the same hash is this script's policy id, so it also +// decides which tokens may exist under it. One token in, or one token out. +// #region vault +validator vault(recovery: VerificationKeyHash) { + spend( + datum: Option, + redeemer: VaultAction, + _own_ref: OutputReference, + self: Transaction, + ) { + expect Some(VaultDatum { owner }) = datum + when redeemer is { + Unlock -> list.has(self.extra_signatories, owner) + Recover -> list.has(self.extra_signatories, recovery) + } + } + + // #region mint-handler + mint(_redeemer: Data, policy_id: PolicyId, self: Transaction) { + or { + token_minted(self.mint, policy_id, vault_token, 1), + token_minted(self.mint, policy_id, vault_token, -1), + } + } + // #endregion mint-handler + + else(_) { + fail + } +} + +// #endregion vault + +// Tests. The reader starts them in **testing**, against the vault as it stands +// there, and extends them as the contract grows: the recovery pair in +// **parameters**, the mint trio in **validator purposes**. +// +// Every call passes `recovery` first, the way a parameter always comes first in +// a handler. That is the edit **parameters** makes to the tests written in +// **testing**, where the vault had no parameter to pass. + +// #region spend-tests +const owner: VerificationKeyHash = mock_pub_key_hash(1) + +const stranger: VerificationKeyHash = mock_pub_key_hash(2) +// #endregion spend-tests + +// The backup key the vault is compiled around. It arrives in **parameters**, +// alongside the `Recover` action it guards. +// #region recover-tests +const recovery: VerificationKeyHash = mock_pub_key_hash(3) +// #endregion recover-tests + +// #region spend-tests +const dummy_ref: OutputReference = mock_utxo_ref(0, 0) +// #endregion spend-tests + +// #region mint-tests +const policy: PolicyId = mock_script_hash(0) +// #endregion mint-tests + +// #region spend-tests +test unlock_ok_when_the_owner_signs() { + let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +} + +test unlock_fails_for_a_stranger() fail { + let tx = mocktail_tx() |> required_signer_hash(True, stranger) |> complete() + vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +} + +// #endregion spend-tests + +// #region recover-tests +test recover_ok_when_the_recovery_key_signs() { + let tx = mocktail_tx() |> required_signer_hash(True, recovery) |> complete() + vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) +} + +// The important one: the two actions are genuinely separate. Being the owner +// does not let you take the `Recover` path, and vice versa. +test recover_fails_when_the_owner_signs() fail { + let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) +} +// #endregion recover-tests + +// A property, not an example: the rule has to hold for *every* owner, not just +// the one the tests happen to name. Aiken generates the keys and, if it finds a +// failure, shrinks it to the smallest one that still fails. +// #region vault-property +test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { + let tx = mocktail_tx() |> required_signer_hash(True, any_owner) |> complete() + vault.spend(recovery, Some(VaultDatum { owner: any_owner }), Unlock, dummy_ref, tx) +} +// #endregion vault-property + +// The parameter comes first in every handler, `mint` included. +// #region mint-tests +test mint_ok_for_a_single_token() { + let tx = mocktail_tx() |> mint(True, 1, policy, vault_token) |> complete() + vault.mint(recovery, Void, policy, tx) +} + +test burn_ok_for_a_single_token() { + let tx = mocktail_tx() |> mint(True, -1, policy, vault_token) |> complete() + vault.mint(recovery, Void, policy, tx) +} + +test mint_fails_for_more_than_one() fail { + let tx = mocktail_tx() |> mint(True, 2, policy, vault_token) |> complete() + vault.mint(recovery, Void, policy, tx) +} +// #endregion mint-tests diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak new file mode 100644 index 0000000000..c3dacbccb3 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak @@ -0,0 +1,106 @@ +// This is not a second contract. It is `vault.ak` as the reader has it part way +// through the track: the vault with a working rule, but no `recovery` parameter +// and no mint handler yet. Those arrive in **parameters** and **validator +// purposes**, and `vault.ak` beside this file is where the track ends up. +// +// Lectures 4, 5 and 6 render their code from here, so what those pages show is +// the file as it looks at that moment rather than the finished version carrying +// fields the reader has not met. It lives in `validators/` so the compiler +// checks it: the code in a lecture should be code that compiles, not code that +// only looks right. +// +// It therefore gets its own `vault_simple.*` entries in `plutus.json`. Nothing +// reads them. The entries that matter are `vault.vault.*`, from `vault.ak`. + +use aiken/collection/list +// #region datum-imports +use aiken/crypto.{VerificationKeyHash} +use cardano/transaction.{OutputReference, Transaction} +// #endregion datum-imports + +// What the tests need. `mocktail` builds a pretend transaction context, the +// thing the transaction context lecture just finished describing, and the two +// `virgin_*` modules invent the key hashes and output reference to fill it. +// #region simple-test-imports +// #region simple-fuzz-import +use aiken/fuzz +// #endregion simple-fuzz-import +use mocktail.{complete, mocktail_tx, required_signer_hash} +use mocktail/virgin_key_hash.{mock_pub_key_hash} +use mocktail/virgin_output_reference.{mock_utxo_ref} +// #endregion simple-test-imports + +// The datum: who owns *this* locked UTxO. It changes from one UTxO to the next, +// so it belongs on the UTxO. +// #region types +pub type VaultDatum { + owner: VerificationKeyHash, +} +// #endregion types + +// The redeemer: the action the spender is taking. At this stage there is only +// one, which is why the vault does not yet look at it. +// #region types +pub type VaultAction { + Unlock +} + +// #endregion types + +// The vault at lecture 5: it reads the owner out of the datum and asks whether +// that owner signed the transaction. One line of rule. +// #region validator +validator vault { + spend( + datum: Option, + _redeemer: VaultAction, + _own_ref: OutputReference, + self: Transaction, + ) { + expect Some(VaultDatum { owner }) = datum + // #region rule + list.has(self.extra_signatories, owner) + // #endregion rule + } + + else(_) { + fail + } +} +// #endregion validator + +// Tests. The reader writes these in **testing**, against the vault as it stands +// at that point: no parameter yet, so `spend` is called with the four arguments +// the handler declares and nothing in front of them. + +// #region simple-tests +// Two keys that are not each other, and one output reference the vault never +// reads. `mock_pub_key_hash` just turns a number into a valid 28-byte hash. +const owner: VerificationKeyHash = mock_pub_key_hash(1) + +const stranger: VerificationKeyHash = mock_pub_key_hash(2) + +const dummy_ref: OutputReference = mock_utxo_ref(0, 0) + +test unlock_ok_when_the_owner_signs() { + let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +} + +// The rule is only worth anything if it also says no. `fail` is how a test +// asserts refusal: this passes when the validator rejects the transaction. +test unlock_fails_for_a_stranger() fail { + let tx = mocktail_tx() |> required_signer_hash(True, stranger) |> complete() + vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +} +// #endregion simple-tests + +// A property, not an example: the rule has to hold for *every* owner, not just +// the one the tests happen to name. Aiken generates the keys and, if it finds a +// failure, shrinks it to the smallest one that still fails. +// #region simple-property +test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { + let tx = mocktail_tx() |> required_signer_hash(True, any_owner) |> complete() + vault.spend(Some(VaultDatum { owner: any_owner }), Unlock, dummy_ref, tx) +} +// #endregion simple-property From bd6aab85525bfca11fd9055357943f4f248e8949 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 11:48:46 -0300 Subject: [PATCH 04/89] Add Intermediate lectures 1 to 9 --- .../intermediate/1-on-chain-vs-off-chain.md | 111 +++++ .../lectures/intermediate/2-tools.md | 118 +++++ .../intermediate/3-what-is-a-validator.md | 165 +++++++ .../intermediate/4-datum-and-redeemer.md | 208 +++++++++ .../intermediate/5-transaction-context.md | 168 +++++++ .../lectures/intermediate/6-testing.md | 194 ++++++++ .../lectures/intermediate/7-parameters.md | 190 ++++++++ .../intermediate/8-validator-purposes.md | 223 +++++++++ .../intermediate/9-frontend-integration.md | 423 ++++++++++++++++++ .../lectures/intermediate/introduction.md | 149 +++++- .../lectures/intermediate/lecture-1.md | 5 - .../onboarding/lectures/introduction.md | 2 + sidebars.js | 10 +- 13 files changed, 1958 insertions(+), 8 deletions(-) create mode 100644 docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md create mode 100644 docs/developers/onboarding/lectures/intermediate/2-tools.md create mode 100644 docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md create mode 100644 docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md create mode 100644 docs/developers/onboarding/lectures/intermediate/5-transaction-context.md create mode 100644 docs/developers/onboarding/lectures/intermediate/6-testing.md create mode 100644 docs/developers/onboarding/lectures/intermediate/7-parameters.md create mode 100644 docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md create mode 100644 docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md delete mode 100644 docs/developers/onboarding/lectures/intermediate/lecture-1.md diff --git a/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md new file mode 100644 index 0000000000..0a8336ce9a --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md @@ -0,0 +1,111 @@ +--- +title: "On-chain vs off-chain" +sidebar_label: "On-chain vs off-chain" +description: "Every Cardano app has two halves: off-chain code that builds transactions, and an on-chain contract that enforces the rules." +--- + +# On-chain vs off-chain + +Welcome to the Intermediate track. In Beginner you moved value around. Now you will make the chain **enforce rules** about how that value moves. That is a **smart contract**. + +One idea has to be clear before any code, because the rest of the track is built on it. The apps you built [in Beginner](/docs/developers/onboarding/lectures/beginner/introduction) had only one half: your code. Add a smart contract and there is a second half. The two do completely different jobs. + +- **Off-chain** is the code that runs **on your computer or server** (your app, plus an off-chain SDK). It reads the chain, **builds transactions**, and asks the wallet to sign them. This is the same work you did for the [send](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) and [mint](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) transactions in Beginner. It **prepares**. +- **On-chain** is the **smart contract that lives on the blockchain**. It is a rule that runs when someone tries to spend locked funds, and it either **approves or rejects** the transaction. It **enforces**. + +Think of applying for a permit to build something. Your app is the person applying: it decides what it wants to build, fills in every field, and hands the form in. The contract is the officer who reads the form and either approves it or rejects it. The person can ask for anything, and the officer decides what is allowed. Notice what the officer never does. They do not decide what to build, and they do not build it themselves. They only decide yes or no. + +```mermaid +flowchart LR + subgraph OFF["Off-chain: runs on your machine"] + App["your app + SDK
builds the transaction"] --> Wallet["wallet
signs it"] + end + + subgraph ON["On-chain: runs on the network"] + Chain[("Cardano
network")] -->|runs the contract| Validator{"validator
yes / no"} + Validator -->|yes| Done["recorded on the chain"] + Validator -->|no| Rejected["rejected, nothing changes"] + end + + Wallet -->|submits| Chain +``` + +Read it from left to right. Everything in the left box is your side of the line. It is work your code does before anything is final. As soon as the transaction is sent, control passes to the chain, and the validator makes the final decision. Notice that no arrow comes back. The contract cannot ask your app for more information, and your app cannot change the answer. + +## Who does what + +Split any Cardano app along that line and it becomes much easier to understand: + +| Off-chain (your code, your machine) | On-chain (the contract, the network) | +|---|---| +| Read the chain: which UTxOs exist, what's locked where | - | +| Decide what _should_ happen | Check whether it's **allowed** | +| Pick the inputs, build the outputs, balance the fee | - | +| Attach the datum and the redeemer | Read the datum and the redeemer | +| Collect the wallet's signature | See which signatures are on the transaction | +| Submit | Answer **yes** or **no** | + +Almost every line is on the left. The next section explains why. + +## Why the split exists + +The chain has to reach the **same answer for everyone, forever**. A node checking your transaction today and a node checking that same block ten years from now must both decide the same way. If they did not, they would disagree about who owns what. So a contract may only look at things that are **written down**: the transaction itself, the outputs it spends, and the validity window it declares. + +That single requirement explains most of what feels strange at first: + +- **A contract cannot call an API**, read a price feed, or fetch anything. Two nodes asking the same server could get two different answers. +- **A contract cannot read a clock.** This is why time became a **slot window** that you declare in advance, back in [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano). +- **A contract keeps no variables of its own between runs.** This does not mean nothing is saved. On Cardano, state lives **on the UTxOs** rather than inside the contract, and everything the validator needs to know must reach it through the transaction. The next two lectures show how. +- **A contract cannot start anything.** Nothing on Cardano happens because a contract decided to act. Someone has to build a transaction first. + +You get something valuable in return. Because nothing is measured at the moment of checking, your app can have the contract run **before sending the transaction** and already know what it will answer. A spend the contract would refuse is caught then, and never has to be sent. + +That is a promise about the **contract's answer**, not about the transaction getting in. Somebody else may spend the same UTxO first, and then the ledger refuses yours before the contract is even consulted. So the guarantee is: **if** your transaction is accepted, it does exactly what you predicted. Not that it is certain to be accepted. + +## Two things that surprise newcomers + +**The contract does not run on your computer.** You write it, compile it, and read it in your editor, so it is easy to think of it as part of your app. It is not. Your app carries the compiled contract **inside the transaction**, and the **network** runs it when that transaction is checked. The answer is the same for everyone, forever. + +**The contract cannot _do_ anything.** It never sends funds, never updates a balance, and never changes data on its own. Every movement of value in this track is done by a **transaction your off-chain code built**. All the contract ever adds is a yes or a no. All the action is off-chain, and all the enforcement is on-chain. Remember that sentence, because the rest of this track repeats it in different forms. + +## Try it + +**Make the folder you will work in for the rest of the track.** It holds the two halves of this lecture, one each, and you fill them yourself: + +```bash +mkdir cardano-vault +cd cardano-vault +mkdir on-chain +mkdir off-chain +``` + +:::note Which terminal, and where you are +These commands work as written on macOS and Linux, and in **PowerShell** on Windows. If you use the older Windows `cmd` prompt, one command later in the track differs: `rm` is `del`. + +If a command ever answers **"no such file or directory"**, run `pwd` and check which folder you are standing in. +::: + +``` +cardano-vault/ +├── on-chain/ <- the rules. Compiled, hashed, enforced by every node. +└── off-chain/ <- the app. Runs on your machine and builds transactions. Enforces nothing. +``` + +Both are empty. The next lecture puts a contract project in `on-chain/` and leaves you working inside it. `off-chain/` stays empty until **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, which is the one place in the track you change folder again. + +Keep the name or pick your own, and read `cardano-vault/` as "wherever you put it". + +Which half a file belongs in is always worth knowing. Code in `off-chain/` can be wrong, or replaced. The network does not care, because it checks every transaction against what is in `on-chain/`. Code in `on-chain/` is the part the network enforces. + +For the vault you are about to build, the split runs like this. Off-chain builds a transaction that sends ADA to the contract's address, which locks it. Later, off-chain builds a second transaction that tries to spend it back, so on-chain, the validator runs and answers yes or no, and only a "yes" allows the spend. Every contract you write follows this shape. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — the on-chain/off-chain split in full. +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — the lock-then-spend flow end to end. +- [Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers) — the account-model habits that don't carry over: no `msg.sender`, no contract storage, no execution order. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — the ledger model that makes this split possible. + +Next: **[Set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. diff --git a/docs/developers/onboarding/lectures/intermediate/2-tools.md b/docs/developers/onboarding/lectures/intermediate/2-tools.md new file mode 100644 index 0000000000..79e28e5c59 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/2-tools.md @@ -0,0 +1,118 @@ +--- +title: "Set up your tools" +sidebar_label: "Set up your tools" +description: "The language and compiler for the on-chain half, and the contract project everything else in this track fills." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Set up your tools + +Two halves, two sets of tools. The on-chain half needs a **language and a compiler**, because a rule has to become a program the network can run. The off-chain half needs a **library, a provider and a wallet**, because your app has to build transactions, read the chain and get things signed. + +**You only need the first set now.** The next six lectures are the contract and nothing else: you write it, compile it, test it, and finish it. The app comes afterwards, once the contract is done, so the focus stays on the contract's own concepts. The off-chain half then arrives all at once in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. + +So this lecture sets up one project, and leaves it empty. Everything up to lecture 8 fills it. + +## The on-chain toolchain + +You do not write the code the network runs. You write it in a high-level language and **compile** it. Several languages do this for Cardano: + +- **[Aiken](https://aiken-lang.org/)** is made for Cardano contracts. It is a small language with a fast compiler and built-in tests, and it is the easiest place to start. This track uses it. +- **[Scalus](https://scalus.org/)** lets teams who already use Scala write contracts in the language they know. +- Others exist for Haskell, Python and TypeScript teams. The [handbook compares them](/docs/developers/curriculum/smart-contracts/choose-a-language), and **[Builder Tools](/tools)** lists them all. + +Different languages, **same output**. They all compile to the same low-level program, and they all describe it in the same file format, the **CIP-57 blueprint**. Your off-chain code reads that file and never needs to know which language made it. So the choice matters for your team, not for the chain. + +## The off-chain toolchain, so you know what is coming + +Nothing to install here. This is the shape of the other half, so that the choices you make now make sense. Three pieces, each with one job. + +**The SDK** builds Cardano transactions for you. Without one, every transaction would cost you a lot of time and a lot of code. The examples in this track are written with **[Mesh](https://github.com/MeshJS/mesh)**, and every code block sits in a tab, with **[Evolution](https://github.com/IntersectMBO/evolution-sdk)** beside it. **[Tx3](https://github.com/tx3-lang/tx3)** works differently, you describe the transaction, and it generates the code. + +Those three are a choice, not a rule. There are SDKs for Python, Haskell, Java, Go and more, and **[Builder Tools](/tools)** lists them by language. Nothing in these lectures depends on the one you pick: the contract is the same, the transaction is the same, only the function names change. + +**The provider** reads the chain for you and submits your transactions, because your app cannot reach the network by itself. Beginner used one already. This track leans on it harder, for two reasons: + +- **You read UTxOs that are not yours.** Locked funds sit at a contract's address. Your wallet knows nothing about them, so the provider is the only way to find them. +- **A script transaction has to declare its cost.** Running a validator uses CPU and memory, and the transaction carries the budget it expects to use, written next to the redeemer. You also pay for that budget in the fee. So something has to run the contract first, against your unsigned transaction, to find the real number. Your SDK can do that on your machine, or hand the job to a provider that offers it. Either way the answer arrives before you send anything, which is why a contract that says no usually fails in your app rather than on the chain. + +You made a free **[Blockfrost](https://blockfrost.io/)** Preview key during setup. That is the provider. Others are listed in **[Builder Tools](/tools)**, and some of them you can run yourself. + +**The wallet** holds the keys and signs. Your app never sees a private key: it hands the finished transaction to the wallet, the wallet asks the user, and the user approves. Here that is **[Lace](https://www.lace.io/)** on Preview. + +So: **SDK builds and prices, provider reads and submits, wallet signs.** The network decides. + +Keep your Blockfrost key and your Lace wallet where they are. Neither is touched again until **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, which sets all three of these up in one go. + +## Try it + +**Set up the contract project.** No contract in it yet, **[the next lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** writes that. This one gets the project compiling. + + + + +Install Aiken from the **[installation guide](https://aiken-lang.org/installation-instructions)**. It takes about a minute. Then build the contract project **inside the on-chain half**, so it lands where it belongs instead of being moved there afterwards: + +```bash +cd on-chain +aiken new my-name/vault +cd vault +``` + +`aiken new` creates the folder in whichever folder you run it from, and fills it with a working project: `aiken.toml` for the settings and dependencies, and `validators/` for your contracts. The name is `{organisation}/{repository}`, the same form as the dependencies you will add later, so `my-name/` is a label you can set to anything and `vault` is what the project is called. + +That last `cd` matters more than it looks. Aiken's commands act on the project you are standing in, `aiken add` among them, so the next six lectures all run from inside `on-chain/vault/`. You leave it once, in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and that is the last folder change in the track. + +`aiken new` leaves a sample validator behind. You do not need it, and it would end up in your compiled output, so delete it: + +```bash +rm validators/placeholder.ak +``` + +Libraries come later, when a lecture needs one. + +Check that the project works: + +```bash +aiken check +``` + +It compiles and reports `"total": 0` tests, because the project is empty. That is the answer you want here. + +Your contracts go in `validators/`. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the tooling differs. + + + + +Your workspace now has something in one half of it: + +``` +cardano-vault/ +├── on-chain/ +│ └── vault/ <- you are here, and stay here until lecture 9 +│ ├── aiken.toml +│ └── validators/ <- your contracts +└── off-chain/ <- still empty, filled in lecture 9 +``` + +That empty `off-chain/` is not an oversight. It is the shape of the work: the contract is what you are learning, and it changes with every idea in the next six lectures. The app(off-chain) that drives it barely changes at all, which is why it is worth writing once, at the end, against a contract that has stopped moving. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Choose a Smart Contract Language](/docs/developers/curriculum/smart-contracts/choose-a-language) — Aiken, Scalus and the rest, and why they all compile to the same core. +- [Choose your tools](/docs/developers/curriculum/start-building/choose-your-tools) — how to pick an off-chain library. +- [Builder Tools](/tools) — every SDK, library and API on the portal, filtered by language. +- [Use a provider](/docs/developers/curriculum/production/use-a-provider) — hosted, self-hosted, and local options. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — reading addresses, UTxOs and datums. +- [Testing](/docs/developers/curriculum/smart-contracts/testing) — unit tests, property tests, and how far you can get before touching a chain. + +Next: **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**. diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md new file mode 100644 index 0000000000..4795f7c361 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -0,0 +1,165 @@ +--- +title: "What a validator is" +sidebar_label: "What a validator is" +description: "A smart contract on Cardano is a validator: a small yes/no function the network runs to approve or reject spending a locked UTxO." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# What a validator is + +A smart contract on Cardano is a **validator**: a small function the network runs when someone tries to spend a **locked** UTxO. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the spend happens. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. + +Here is the part that surprises people: **a validator never moves funds.** It is not a program that holds money and pays it out. It is a **guard at a door**. A guard does not carry anything in or out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. It works the same way here. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. + +So a validator is defined by what it **refuses**. A guard who lets everyone through is not guarding anything. Writing a contract means choosing the cases where you say no. + +One more point before we continue. "A smart contract" does not always mean *one* validator. A real application often uses a **set of validators that work together**. Each one protects its own thing, and each one judges the same transaction. Our examples use a single validator for now. **Multi validators** shows one script guarding two different actions at once, and **reference inputs** shows two separate contracts working together. + +## Where the locked funds live + +Remember from Beginner that a [UTxO](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) (a "sealed bag") always sits at an **[address](/docs/developers/onboarding/lectures/beginner/wallets-keys-addresses)**. Most of the addresses you have used belong to a person. These are **key addresses**, and whoever holds the matching private key can spend what is there. + +You have already met the other kind. When Bob locked 5 ADA behind a native script in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata), the funds went to a **script address**, controlled by a **rule** instead of a person. A validator uses the same kind of address. The only difference is how complex the rule is allowed to be. + +```mermaid +flowchart TB + subgraph K["Key address — controlled by a person"] + KA["10 ADA sitting here"] --> KR["to spend it:
sign with the matching private key"] + end + + subgraph S["Script address — controlled by a rule"] + SA["5 ADA sitting here"] --> SR["to spend it:
build a transaction the validator approves"] + end + + K ~~~ S +``` + +Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. The only difference is what it takes to open them. A key address asks _"is this signed by the right key?"_. A script address asks _"does the validator say yes?"_. A script address has no key, no recovery phrase, and nobody who can give permission. Even the person who wrote the contract has to satisfy the rule like everyone else. + +Where does that address come from? From the validator itself, using the same hashing you saw there. You hash the compiled contract, and that fingerprint becomes the address. Change one character of the contract and you get a completely different address, guarding completely different funds. You will do exactly this, in three calls, in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. + +## Locking is just a payment + +Here is the part that catches almost everyone out: **the contract does not run when you lock funds.** + +Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The network runs nothing, because there is nothing to approve. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. The contract does not run at all. + +It runs only when someone tries to **spend** that UTxO. At that moment the network takes the validator, gives it the transaction, and asks its one question. + +```mermaid +flowchart LR + W["your wallet"] -->|"lock
(an ordinary payment,
nothing runs)"| U["UTxO at the script address
5 ADA + datum"] + U -->|"unlock
(a spend, so the
validator runs)"| V{"validator
yes / no"} + V -->|yes| Yes["the 5 ADA moves
wherever the transaction says"] + V -->|no| No["transaction rejected,
the UTxO stays put"] +``` + +So a validator only checks funds on the way **out**, never on the way in. Anyone can send funds in, even by mistake, and nothing checks them. Taking them out is the only guarded step. This matters more than it first appears, and it is the shape of every contract in this track. You lock first, and all the interesting logic happens at the spend. + +## What the validator sees + +A validator guarding a locked UTxO is a function of three things: + +``` +validator(datum, redeemer, context) -> True | False +``` + +- **datum** the information attached to the locked UTxO, +- **redeemer** what the spender provides when unlocking, +- **context** the whole transaction around it. + +In code you will see **four** arguments rather than three, because the context arrives in two pieces: the transaction, and a pointer to the exact UTxO being spent. The idea is still these three. + +It is **handed** nothing else. No network access, no clock, no storage, and nothing about the world beyond what it is given. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next two lectures cover all three in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds the one route that does not go through this list at all. For now, remember the shape: **information in, one yes or no out.** + +:::warning A validator is only as good as what it refuses +Think about the two simplest validators possible, and you will see the full range you are working in: + +- **Always true** returns `True` no matter what, so **anyone** can spend the funds, for any reason, at any time. +- **Always false** returns `False` no matter what, so **nobody** can ever spend them. The funds are **permanently unspendable**: not by you, not by the person who locked them, not by anyone, ever. + +Real people have shipped both of these by mistake. A contract that always passes gives the funds away to whoever asks first. A contract that always fails means nobody can ever move them ([locked value](/docs/developers/curriculum/smart-contracts/security#locked-value) in the handbook). Neither mistake can be undone. Real validators sit between these two and say yes only when specific conditions are met. This is also why you test the vault in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**, and why those tests are mostly about what it refuses. +::: + +## Try it + +**Write both extremes and compile them.** You write one file and change one word in it, so you see the pair from the box above: the validator that always says yes, and the one that always says no. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +Now the contract itself: the smallest one that compiles, and it says yes to everything. Create the file `validators/vault.ak` and put this in it. Copy it as it is: **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** explains the arguments, and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** explains the `else` block. + +```aiken title="validators/vault.ak" +validator vault { + spend(_datum: Option, _redeemer: Data, _own_ref, _self) { + True + } + + else(_) { + fail + } +} +``` + +`validator vault` names the script. `spend` is the handler that runs when someone tries to spend a locked UTxO. The underscore in front of each name means "given, but not used here", so this contract ignores everything it is handed. + +Four arguments, three ideas. `_datum` and `_redeemer` are the first two from the list above. The context is the other two together: `_own_ref` points at the UTxO being spent, and `_self` is the whole transaction. What is inside it is the subject of **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. + +The body is the entire rule: `True`, yes to everybody. + +```bash +aiken check +``` + +It compiles. Now change `True` to `False` and run it again. The result is **identical**: no error, no warning. Both are valid contracts. One gives the funds to whoever asks first, the other locks them away from everyone forever, and the compiler has no opinion about either. Only you decide what your contract refuses. + +Put `True` back, and compile it for real: + +```bash +aiken build +``` + +**Check you wrote the same contract.** That build wrote a file called `plutus.json`, which the next section goes through. Open it and find the `hash` under the `validators` list. Compare it with ours: + +``` +d27ccc13fab5b782984a3d1f99353197ca1a81be069941ffc003ee75 +``` + +If it matches, your validator compiles to exactly the same script as ours, byte for byte, which means the same address. If it does not, something in the file differs from the code above, so copy it again. Make sure `True` is back in place, because the `False` version compiles just as happily and gives a different hash. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## What compiling produced + +Compiling wrote **`plutus.json`**, next to `aiken.toml`. This is the **blueprint**: the compiled contract, described in a format every Cardano language shares. Your off-chain code reads this file and turns it into an address, which you will see done in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. + +Open it. Four things are inside: + +- **`preamble`:** who built it, with which compiler, and which Plutus version (`v3` here). +- **`validators[]`:** one entry per **purpose**, titled `file.validator.purpose`. Yours has two, `vault.vault.spend` and `vault.vault.else`, and they share one `hash`. That hash is the fingerprint from earlier in this lecture: the contract's identity, and the value its address is built from. Why one script has several entries under it is the subject of **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. +- **`compiledCode`:** the actual program, as a hex string. This is the **only** part the network ever runs. It is a low-level language called UPLC, and every contract language compiles down to it. +- **`definitions`:** the shapes of your datum and redeemer types, which is [the next lecture](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer). Right now they are just `Data`, because your validator accepts anything. + +Notice what is **not** in there: the address. It is not part of the compiled output. It is built from the hash, and it depends on which network you are on. + +## Go deeper + +- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator) — the gatekeeper model, with real validator code. +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — "validators, not actors." +- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses) — key addresses, script addresses, and how each one is built. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security#locked-value) — the "locked value" section, on what actually happens when a validator can never say yes. + +Next: **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md new file mode 100644 index 0000000000..57a74f0a5f --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -0,0 +1,208 @@ +--- +title: "Datum & redeemer" +sidebar_label: "Datum & redeemer" +description: "The two pieces of data a validator works with: the datum locked with a UTxO, and the redeemer the spender provides." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak"; + +# Datum & redeemer + +[Last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator) said a validator is a function of **datum**, **redeemer**, and **context**. The validator you wrote ignores all three. This lecture is about the first two, which are how you give information to a contract. They are also the part newcomers most often confuse, so they are worth explaining carefully. + +- The **datum** is information **attached to the locked UTxO** when you lock it. These are the _terms_. Think of it as a note that says "this is locked under these conditions". It is fixed the moment the funds are locked and never changes. +- The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt, and it is supplied fresh in the spending transaction. +- The **context** is the rest of the transaction: its inputs, outputs, signatures, and the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano). The validator can read all of it. There is enough of it to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. + +Imagine you leave a bag with someone for safe keeping. That bag is a **UTxO**. When you hand it over, they attach a note that says "give this back only to the person holding ticket 42". That note stays with the bag, and it is the **datum**. Later somebody arrives and says what they want: "I am here to collect the bag." That request is the **redeemer**. The note alone decides nothing, and the request alone decides nothing. The decision needs both together, plus the situation they arrive in, which is the context. + +So: **datum is what was set at lock time, and redeemer is what the spender says now.** Add the context, and the validator considers all three and returns yes or no. + +```mermaid +sequenceDiagram + participant You as Your app + wallet + participant Net as Network + participant Vault as The script address
(no wallet, no keys, no owner) + + Note over You,Vault: Transaction 1 — locking + You->>Net: sign + submit a payment to the script address,
with the datum attached + Net->>Vault: an ordinary payment, accepted. The 5 ADA sits here + Note over Net: the validator does not run:
nothing is being unlocked yet + Note over Vault: 5 ADA + datum (the terms): owner = your key hash + + Note over You,Vault: later. Anyone may try to spend that UTxO + + Note over You,Vault: Transaction 2 — unlocking + You->>Net: sign + submit a spend of that UTxO,
with the redeemer attached + Net->>Net: run the validator on three things:
the datum (read off the UTxO), the redeemer (from this transaction),
and the context (this transaction itself) + Net-->>You: True, or False + Note over Net,You: True → the 5 ADA moves where transaction 2 says
False → transaction 2 is rejected, the UTxO stays put +``` + +Two transactions, and only the second one is judged. Everything the **datum** says was settled in +transaction 1, by whoever locked the funds, and it cannot be changed now. Everything the **redeemer** +says is what the spender brings today, in transaction 2. The validator's whole job is to check the +second against the first, in the situation the context describes. + +## A tiny example + +Our example contract is a **vault**. It locks some funds so that only their owner can take them back. The datum names the **owner**, and the redeemer is the **action** the spender is taking. Here there is only one action, `Unlock`. Here are those two types on-chain: + + + + + + {extractRegion(VaultSimple, "types")} + + +Read it as two shapes being declared: + +- `VaultDatum` has a single field, `owner`, of type `VerificationKeyHash`. That is a **public key hash**, the short fingerprint of a public key. Native scripts used the same thing to name a signer back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It says who must sign, but it is not a key itself. +- `VaultAction` has a single choice, `Unlock`. A larger contract would list several, such as `Unlock`, `Cancel` and `Extend`, and the validator would check which one the spender chose. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## How those shapes are stored + +Those two declarations look like ordinary types, but they describe **bytes on the chain**, and it is worth knowing what those bytes are before anything has to produce them. + +On-chain data is stored as a **numbered constructor plus a list of fields**. The number answers "which choice of the type is this?" and the list answers "what does it hold?". The number is assigned by position: the first choice declared is 0, the next is 1, and so on. Neither of our types offers a choice yet, since `VaultDatum` has one shape and `VaultAction` has one action, so both are constructor **0**: `VaultDatum { owner }` is constructor 0 carrying one field, and `Unlock` is constructor 0 carrying nothing. + +The number only starts to matter once a type offers a real choice. Had `VaultAction` listed the three actions mentioned above, they would be numbered in the order they are declared: + +```aiken +pub type VaultAction { + Unlock // constructor 0 + Cancel // constructor 1 + Extend // constructor 2 +} +``` + +That numbering is the contract's half of an agreement. Something has to build the same bytes from the other side, and nothing checks that the two agree: send constructor 1 when you meant `Unlock` and the validator reads `Cancel`, and acts on it. Writing that other half is what **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does, once the contract has stopped changing shape. You will meet a real two-choice redeemer before then, in [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters), where the vault gains a second action. + +:::warning A wrong datum can't be undone +The chain does not check that your datum matches what the validator expects. It stores whatever bytes you attach. If you get the shape wrong, with the wrong constructor number, the wrong number of fields, or the fields in the wrong order, the mistake is not caught at lock time, because the contract does not run when you lock. It is caught later, when the validator tries to read the datum, **fails**, and answers no. Every time, for everyone. + +The funds are then permanently unspendable. There is no way to undo it and nobody who can help. This is one of the common ways people lose funds on Cardano. It is why the matching above needs care, and why the vault gets a full set of tests in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**. +::: + +## Where the datum actually lives + +There are two ways to attach a datum to an output, and it helps to know which one you are using: + +- **Inline**: the whole datum is written into the output itself, visible on the chain. This is what our example does. The lock writes the datum into the output, and the spend only has to say that it is already there, so no copy is needed. +- **By hash**: the output stores only a **hash** of the datum. Whoever spends it must supply the matching datum in their transaction. The output is smaller, but the spender must have kept the datum somewhere, and it is not a hiding place: spending publishes the whole datum on the chain anyway. If they lose it, they cannot produce it, and the funds stay locked exactly as in the warning above. + +Inline is the newer of the two and the better default. The datum travels with the output, so anyone who can see the UTxO can read its terms, and nobody has to come to you for a copy. + +:::danger Everything on-chain is public +The datum and the redeemer are stored **openly** on the blockchain, and anyone can read both. So a contract can **never keep a secret**. Do not put a password, a private number, or a "guess this number" puzzle in a datum, because everyone can see it. + +This is why our vault's datum holds only the owner's **public** key hash, and the real lock is a **signature**. Data can be read, but a signature cannot be faked. Contracts protect funds with things a spender cannot fake: **signatures, tokens, and time**. Never with hidden data. +::: + +:::tip Datum for state, redeemer for action +A useful guide for the rest of this track: put the **facts that must be kept** (here, the owner) in the datum, and the **action the spender is taking** (here, `Unlock`) in the redeemer. The validator then checks the context. Our vault checks that the transaction is **signed by that owner**. +::: + +## Try it + +**Give your vault the two shapes.** Right now it accepts anything, and it does not even know what it is being handed. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +Open `validators/vault.ak`, the file you wrote [last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator). It has one `validator` block in it and nothing else. + +The shapes you are about to write need two things from the standard library: a type for the owner's key hash, and the types the handler is handed. Add both as the **first lines of the file**: + + + {extractRegion(VaultSimple, "datum-imports")} + + +Then write the datum and the redeemer themselves, **between the imports and the `validator` block**. These are the two shapes from the start of this lecture: + + + {extractRegion(VaultSimple, "types")} + + +Last, **replace the whole `validator` block** with this one. The rule has not changed, it still ends in `True`, but the handler now says what it expects to be handed, and reads the owner out of it: + +```aiken title="validators/vault.ak" +validator vault { + spend( + datum: Option, + _redeemer: VaultAction, + _own_ref: OutputReference, + _self: Transaction, + ) { + expect Some(VaultDatum { owner }) = datum + True + } + + else(_) { + fail + } +} +``` + +Save it, and: + +```bash +aiken check +``` + +Green. Two lines are worth a moment, because they are doing more than they look: + +- `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. +- `expect Some(VaultDatum { owner }) = datum` means "there must be a datum, it must be a `VaultDatum`, and I want its `owner`". If any of that is untrue the validator fails and the spend is refused. This is the line the warning above describes: it is where a mismatched datum gets caught, long after it was attached. + +The contract still returns `True`, so it still gives the funds to anybody. But it now insists on being handed a note it can read, and it knows the owner. The [next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) is where that owner starts deciding things. + +**Check you wrote the same contract.** Build it, so the compiler writes out the blueprint: + +```bash +aiken build +``` + +Open `plutus.json` and find the `hash` under the `validators` list. Compare it with ours: + +``` +49f60f50cd2bdf1b06554e5b58adbbc86da3cc129bc5f80dc878591d +``` + +If it matches, your vault compiles to exactly the same script as ours, byte for byte, which means the same address. If it does not, something in the file differs from the code above, so go back over the imports, the two types and the handler. The hash will change again in the [next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context), because the contract does. + + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +**And the redeemer?** You cannot watch it decide anything yet, and that is worth saying plainly rather than inventing a contract to hide it. `VaultAction` offers one choice, so every spender sends the identical thing and it changes no outcome. A redeemer only starts doing work once there is more than one action to pick from, which is what happens in **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context) — the full model, with a vesting example. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — how a datum rides along on an output. +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — datum and redeemer inside a complete lock/spend flow. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — reading datums back out from your app. + +Next: **[The transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md new file mode 100644 index 0000000000..6172a8280f --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -0,0 +1,168 @@ +--- +title: "The transaction context" +sidebar_label: "Transaction context" +description: "The third thing a validator is given: the whole transaction it is being asked to approve, and every part of it the contract may look at." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak"; + +# The transaction context + +[The last lecture](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) covered two of the three things a validator is given: the **datum** and the **redeemer**. This one covers the third, and it is much bigger than the other two together. + +The **context** is the **transaction itself**. When someone tries to spend your locked funds, the network hands your contract the entire transaction that is trying to do it, and lets the contract look at any part of it before answering. + +So the datum and the redeemer are two small values you supply. The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent, what is being created, who signed, and the time window the transaction declared. + +A rule that only compares the datum with the redeemer protects nothing. Both are data, and data cannot show who signed, what moved, or when it happened. Only the transaction shows that, which is why nearly every rule you write is a question about the transaction, measured against what the datum says. Your vault is about to ask exactly one: is the owner named in the datum among the keys that signed? + +## What is inside + +The context holds one transaction, described in full. Here is everything in it, grouped so it is easier to hold in your head than a flat list: + +| Group | Fields | What it tells you | +|---|---|---| +| **What comes in** | `inputs`, `reference_inputs` | the UTxOs being spent, and the ones only being read | +| **What goes out** | `outputs`, `mint`, `fee` | the new UTxOs created, tokens made or destroyed, the fee paid | +| **Who and when** | `extra_signatories`, `validity_range` | the keys the transaction requires a signature from, and the time window it declared | +| **The rest** | `certificates`, `withdrawals`, `redeemers`, `datums`, `id`, and the governance and treasury fields | staking, voting, the transaction's own id, and the datums and redeemers it carries | + +Most contracts use the first three groups and never touch the fourth. + +:::note These names come from the ledger, not from a language +The names above are spelled the way this track's examples spell them, and another language will write some of them a little differently. What the list holds is decided by **Cardano**, not by the tool you write your contract in, so learn it once and it carries over. + +The list also grows. Each version of the on-chain language has added fields: `reference_inputs` arrived with v2, and the governance and treasury fields with v3. A contract sees the shape of the version it was compiled against, the `v3` recorded in its blueprint, from **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**, and it keeps that view for as long as it exists. A later upgrade cannot change what an already deployed contract is shown. +::: + +## One transaction, every script + +A transaction can trigger more than one script: two contracts being spent at once, or a mint and a spend under the same hash. **They are all handed the same transaction.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting, while the facts they judge are identical. + +That is what makes contracts work together on Cardano. They never call each other, because they do not have to: one script can require something of a transaction and rely on another script seeing the same thing. **Multi validators** builds exactly that, a mint and a spend cooperating inside one transaction. + +It also has a sharp edge. If two contracts each demand "5 ADA must go to my address", one output paying 5 ADA to the right place can satisfy both at once, which is not what either author intended. That is called double satisfaction, and the handbook's [security page](/docs/developers/curriculum/smart-contracts/security) covers it and the rest of the family. + +## One field is the whole vault + +Here is the finished vault, the contract you are two steps away from. It is one line of rule, and that line is a question about the transaction: + + + + + + {extractRegion(VaultSimple, "validator")} + + +`self` **is** the context. It is the transaction, handed straight to the handler, and `self.extra_signatories` is one of the fields from the list above: the keys the transaction requires a signature from, which is a list the transaction **declares**, not the signatures themselves. The whole rule is one question about it: _is the owner named in the datum among the signers?_ `list.has` asks whether something is in a list, and that is the entire contract. + +:::note Coming from Ethereum? +There is no `msg.sender` here, and nothing plays that role. A transaction has no single caller, because it can carry many signatures at once. So you never ask "who called me", you ask whether the key you care about is among the signers. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers the rest of that shift. +::: + +Each argument the handler receives comes from somewhere definite. The **datum** was attached to the UTxO when it was locked. The **redeemer** comes from the transaction trying to spend it. `_own_ref` says which UTxO that is, and `self` is the transaction itself. + +A `mint` handler is handed a different set, because nothing is being unlocked: no datum, no `_own_ref`, and the policy id instead. What you are given depends on the **purpose**, which has its own lecture in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +The signature alone tells you what this contract ignores. A leading underscore means "handed over, never used", so `_redeemer` and `_own_ref` are ignored: the vault reads its datum, reads the transaction, and looks at nothing else. That is also why the last lecture could not show you a redeemer deciding anything: `VaultAction` offers a single action, so there is nothing to branch on, and the vault does not even look. + +Now imagine `self` had an underscore too. The vault would be left with the datum and the redeemer and nothing else. The datum is public, and the redeemer is chosen by whoever is spending, so every rule you could still write comes down to asking the spender to supply a value they can already read. Signatures, tokens and time all live in the context, and a contract that never looks at the transaction cannot protect anything at all. + +Five of the context's fields carry the rest of this track, and the vault has just shown you the first. The contract shape stays the same each time; only the question changes: + +- `extra_signatories`: only the owner may unlock, in the Try it below. +- `mint`: a contract that controls its own token, in [Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes) and then the gift card in Multi validators. +- `validity_range`: funds that cannot move before a date, in Handling time. +- `inputs` and `outputs`: data that is updated instead of released, in Modifying state. +- `reference_inputs`: reading another contract's data without spending it, in Reference inputs. + +Learning contracts is mostly learning which field answers which question. + +## What is not in it + +The context is generous, but it stops at the edge of one transaction. A contract **cannot** see: + +- **the time**, only the window the transaction declared. [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano) explained why. +- **other addresses**, or what anyone's balance is. +- **the past**: no earlier transaction, and no history of this contract. +- **the rest of the block**: other transactions being confirmed at the same moment are invisible. +- **the metadata**. This one surprises people, because you attached metadata to a transaction back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It is stored on the chain and anyone can read it, but scripts are not shown it. So a contract can never enforce a rule about metadata. + +All of these come back to the same rule from **[on-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**: every node must reach the **same answer, forever**. Anything that could differ between two nodes is left out. + +:::tip The transaction is the whole world +A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums, the UTxOs it references, its outputs, its signatures, its window. No API to call, no database to query, not even the block it sits in. What is not in the transaction does not exist as far as the contract is concerned. + +So a contract never gathers facts, it only judges the ones already in front of it, and **whoever builds the transaction has to put them there**. That is what the datum, the redeemer and the reference inputs are for. The question is never "how does the contract fetch this", it is "who puts it in, and why should the contract believe them". **Modifying state** builds an oracle, which is that question answered. +::: + +## Try it + +**Write the rule, then prove it.** Your vault knows who the owner is, and still says yes to everybody. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +The rule in words: **allow the spend only if the owner named in the datum is among the keys the transaction requires a signature from.** You have every piece. `self` is the transaction, `self.extra_signatories` is that list, `owner` came out of the datum last lecture, and `list.has` answers whether something is in a list. + +In `validators/vault.ak`, make three changes: + +1. Add `use aiken/collection/list` to the imports at the top of the file. +2. In the `spend` handler's arguments, drop the underscore from `_self` so the transaction has a name you can use. +3. Replace the bare `True` at the end of the handler with the rule below: it says yes only if the owner signed. + + + {extractRegion(VaultSimple, "rule")} + + +```bash +aiken check +``` + +Green, and you have written a working validator: a real rule, guarding a real address, refusing everyone who is not the owner. + +**Check you wrote the same contract.** Build it and compare the hash, as you did last lecture: + +```bash +aiken build +``` + +``` +ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570 +``` + +If the `hash` in `plutus.json` matches, your vault is ours byte for byte. Notice it is not the hash you compared in **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. One line of rule changed the script, so it changed its identity and its address, exactly as **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** said it would. + +At least, that is what it is supposed to do. `aiken check` compiled it and said nothing about whether the rule is right, because a compiler never does. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** is next, and it is where you find out. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context) — the full field list, with the checks contracts most often write. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — why a transaction is a complete, self-contained thing to check. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — most real bugs are a context check that was missing. + +Next: **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)**. diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md new file mode 100644 index 0000000000..02beb67060 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -0,0 +1,194 @@ +--- +title: "Testing" +sidebar_label: "Testing" +description: "Unit tests, tracing and property-based tests: proving a validator behaves before it ever holds anything real." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak"; + +# Testing + +A contract has no update button. Once funds sit behind it, a mistake is not a bug you can fix. It is value that is lost or given away. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** put it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. + +So the question is not whether to test. It is how to be sure **before** anything real is at risk. + +[Last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) you wrote a real rule, and every `aiken check` you have run so far has only **compiled** it. The compiler proves the contract is valid Aiken. It cannot tell you whether the rule you wrote is the rule you meant. Your vault would compile just as happily with `list.has` replaced by `True`. + +That is this lecture. It arrives here, in the middle of the track rather than at the end, because everything after it changes the rule: **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** adds a second door and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** adds a mint. Each of those is a change to a contract that already works, and the way you find out you broke something is a test you wrote before you started. + +```mermaid +flowchart LR + U["unit tests
one case you thought of"] --> P["property tests
one rule, a hundred inputs"] --> S["scenario tests
the whole transaction,
never submitted"] +``` + +The first two are this lecture, and they need nothing but the contract. The third needs an app to test against, so it waits for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. + +## Unit tests: the cases you thought of + +The cheapest test builds a **fake transaction**, hands it to the validator, and checks the answer. No network, no wallet, no test ADA, and it finishes in milliseconds. + +Your vault has one rule, so it needs two tests: one person who should get through, and one who should not. + +That second one is the one that matters. Notice the balance: half of these check a **refusal**, and that is the habit worth copying for every contract in this track. **A validator is defined by what it rejects.** A validator that always said yes would pass every success test you could write, which is why a suite of nothing but success tests tells you almost nothing. + + + + +Fake transactions are easier with a helper library. **vodka** is the one this track uses, and its `mocktail` half is the test side: it gives you a way to build a fake transaction, a fake key hash and a fake UTxO reference. You will meet its other half, `cocktail`, in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. + +A test lives in the same file as the contract. That is deliberate rather than untidy: the test and the rule it checks stay side by side, and `aiken build` leaves the tests out of the compiled output entirely. + +Aiken also has one keyword worth knowing before you write anything. Putting **`fail`** after a test's name means "this one is supposed to be refused", so that test passes only when the validator says **no**. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## Tracing: reading a refusal + +This is not a separate level. It is how you read the answer at any of them. + +A validator only ever answers yes or no. That is all the chain needs, but it is thin when a test goes red: you learn *that* the contract refused and nothing about *which* check refused it. Your vault has one check, so there is only one suspect. A contract with a dozen leaves twelve, and by the end of this track yours will have several. + +A **trace** is a line of text the validator writes as it runs, which the test runner prints back to you afterwards. + + + + +The smallest way in is the `?` operator. Put it after any condition: + +```aiken +list.has(self.extra_signatories, owner)? +``` + +Read that `?` as "and tell me if this one came back False". It does not change what the condition does, and it does not change what the validator decides. It only reports the result, and only when that result is `False`. + +That last part is what makes it useful. A check that answered `True` stays silent, so what you get back is a short list of the checks that said no, not a long report of every step that ran. + +**And traces cost nothing on-chain.** That is worth checking rather than believing, because "the validator writes text now" sounds like a bigger, slower, more expensive script. It isn't: `aiken build` strips every trace back out, and the compiled script is byte for byte the one you had before. The Try it below has you prove that with your own hash rather than take it on trust. + +Aiken has a second form, `trace @"your own message"`, for branches that do several things at once and need a label no single condition can give. Your vault's rule is one check, so `?` already says everything a message of your own could. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## Property tests: the cases you didn't + +Unit tests only check the cases you thought of. Your two name one owner, a key you picked. But the rule is not about that key. It is about **any** key: whoever the datum names must be the one who signed. + +A **property test** states that rule directly and lets the test runner go looking for an example that breaks it. Instead of the one key you chose, it generates a hundred and tries every one. + +If any of them fails, it does more than report it. It **reduces** the failing input to the smallest one that still breaks, so you get the exact edge case rather than whichever random value happened to fail first. + +A property test is worth reaching for whenever a rule holds "for all" of something: every key, every amount, every moment after a deadline. You will meet that last one in **handling time**, where the vesting contract arrives with `claim_ok_at_any_time_after_the_deadline` already written. + +## The level these two cannot reach + +Both levels above test the validator **on its own**, and that is also their limit. They hand the contract a transaction you built by hand, in the shape you believe your app will produce. + +Many things go wrong in the gap between those two: a datum built with the wrong constructor number, a missing required signer, a redeemer that does not match. None of these are contract bugs, none of them appear in a contract test, and your vault can be perfect while your app is still unable to open it. + +Closing that gap needs an app to test, so it is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does once there is one: build the **real transaction** with your real off-chain code, then run the **real compiled validator** against it, with no network at all. + +## Try it + +**Prove the rule you just wrote.** Everything below runs from `on-chain/vault/`, where lecture 2 left you. + + + + +**Add the test library.** Your project has had no dependencies but the standard library so far. **vodka** is the first: + +```bash +aiken add sidan-lab/vodka --version 0.1.23 +``` + +That writes three lines into `aiken.toml` for you, and the next `aiken check` downloads the package. `aiken add` acts on the project you are standing in, which is why lecture 2 left you inside `on-chain/vault/` rather than pointing at it from outside. + +**Add the imports.** Three things come from mocktail: `mocktail_tx()` starts an empty transaction, `required_signer_hash(True, key)` puts a key in `extra_signatories`, and `complete()` finishes it. The two `virgin_` modules invent the values to fill them with. Add these to `validators/vault.ak`: + + + {extractRegion(VaultSimple, "simple-test-imports", "simple-fuzz-import")} + + +**Then the tests**, at the bottom of the file, below the validator: + + + {extractRegion(VaultSimple, "simple-tests")} + + +`mock_pub_key_hash(1)` and `mock_pub_key_hash(2)` are just two valid key hashes that are not each other. The vault never reads `dummy_ref`, so any output reference will do. Run them: + +```bash +aiken check +``` + +Two tests, two passes, in milliseconds. This is the cheapest place in the whole system to find out you were wrong. + +**Make a refusal explain itself.** Put a `?` after the check in the `spend` handler, so it reads `list.has(self.extra_signatories, owner)?`, and run `aiken check` again. Both still pass, and nothing needed to break for that to pay off: `unlock_fails_for_a_stranger` now prints the condition you marked underneath itself, with the answer it gave, while `unlock_ok_when_the_owner_signs` stays silent because its check answered `True`. Leave the `?` there while you are still writing the contract. + +**Check what that cost you.** Run `aiken build` and note the `hash` in `plutus.json`. Now take the `?` out, build again, and compare. Same hash, so the same compiled script either way: the trace never reached the chain. Put the `?` back. + +**Break the contract, not the test.** Replace the rule with plain `True` and run `aiken check`. `unlock_fails_for_a_stranger` fails, and it is telling you exactly the right thing: your vault gives its contents to anybody who asks. That one failing test is worth more than the one still passing. Put the rule back. + +**Write the property test.** It needs a library first. Aiken understands property tests on its own, but the **generators** that produce the values are not in the standard library, and neither is the part that reduces a failure to the smallest input that still breaks. They live in a package you install: + +```bash +aiken add aiken-lang/fuzz --version v2.2.0 +``` + +It is only ever used by tests, so nothing it brings in reaches the compiled contract. Build after adding it and the hash is the one you compared in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, unchanged. + +Add its import to the ones you already have: + + + {extractRegion(VaultSimple, "simple-fuzz-import")} + + +Then this at the bottom of the file: + + + {extractRegion(VaultSimple, "simple-property")} + + +`via fuzz.bytearray()` is the difference. Read the signature as a blank to be filled: `any_owner` is a parameter rather than a value you supply, and `fuzz.bytearray()` is the generator that fills it with fresh bytes on every run. A key hash is bytes, which is why that generator fits. The body is the same shape as your two unit tests. + +Run `aiken check` again: it reports the property alongside them, having tried a hundred generated keys. Three tests in total, and the rule is covered for every owner rather than the one you happened to name. + +**Why bother, when the two unit tests already pass?** Because you chose that key yourself, and people choose normal values. A generator does not. It will try an empty key, a very long one, and values you would never think to write down. Your vault says the right thing to all of them, so now you know it rather than hope it. This pays off more later: when a rule compares numbers, such as an amount or a deadline, the mistakes are almost always at the first or last value it accepts, and those are exactly the values a generator tries. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +You now have something the next two lectures need. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** both change a contract that currently works, and each one ends by running these tests again. A change that breaks the rule you just proved will not get past them quietly. + +Every contract in the rest of the track arrives with tests already written, in the same style. Each of those lectures names its own project path, so the command keeps the shape you have been using here. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Testing](/docs/developers/curriculum/smart-contracts/testing) — the test runner, mock transactions, and property testing in depth. +- [Offline testing](/docs/developers/curriculum/start-building/offline-testing) — mocking the chain and evaluating budgets without a node. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — the failure modes worth writing tests against. +- [Audits](/docs/developers/curriculum/smart-contracts/security#audits) — when to bring in outside review, and how to prepare for it. + +Next: **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md new file mode 100644 index 0000000000..9c52fbe8d7 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -0,0 +1,190 @@ +--- +title: "Parameters" +sidebar_label: "Parameters" +description: "A value built into the contract's own code before it has an address, fixed earlier than anything the validator is handed, which is why changing it changes the address." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak"; + +# Parameters + +The last two lectures finished the list of what a validator is **given**: the **[datum and the redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, then the **[context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. That list is closed. Nothing else is handed to a validator when it runs. + +A **parameter** is not on that list, and that is the whole point of this lecture. It is a value built into the contract's own code, before the contract ever reaches the chain. Compiling leaves a **blank** where the value goes, and the contract is finished by filling that blank in. A parameter is not passed **to** the validator, it is baked **into** it, which is why you will never find it in `validator(datum, redeemer, context)`. + +So a parameter is one more way a fact reaches a contract, sitting on a different axis from everything above. Of the values **you** supply, the useful way to tell them apart is **when the value is fixed**: + +| | Fixed when | Lives in | To change it | +|---|---|---|---| +| **parameter** | build time | the contract itself | fill the blank differently: a new contract, at a **new address** | +| **datum** | lock time | the locked UTxO | lock a new UTxO | +| **redeemer** | spend time | the spending transaction | just send a different one | + +The context is missing from that table on purpose. It is not a value you choose and hand over: it is the transaction itself, and it is settled by whoever builds the spend. + +Why would you want one? Think about the vault so far. It releases funds to one owner, proven by one signature. Lose that key and the funds are gone for good, with nobody to ask for help. So we add a **backup key**: a second key, chosen when the vault is made, that can also take the funds out. The owner uses their key for normal spending. The backup is kept somewhere safe and is not touched until it is needed. + +Here are all three ways in, in one small contract: + + + + +It is your vault with the backup key added, and the **[Try it](#try-it)** below makes exactly this change to the `vault.ak` you have been building: + + + {extractRegion(VaultAiken, "types")} + + + + {extractRegion(VaultAiken, "vault", "mint-handler")} + + +Nothing new is imported. `list.has` is the same question your vault has been asking since **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, only asked twice now, about a different key each time. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +Read the three facts and where each one went: + +- `recovery` is the **parameter**, in brackets after the contract's name. It is chosen once, when the contract is built, and it is the same for every UTxO this vault will ever hold. +- `owner` comes from the **datum**, attached when the funds were locked. Each locked UTxO can name a different owner. +- `Unlock` or `Recover` comes from the **redeemer**, chosen by whoever is spending, in that transaction. + +The rule itself should look familiar. It asks the same _is this key among the signers?_ question your vault already asks, reading the signers straight off the transaction as the [last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) described. Only what it is compared against changes, and that is the interesting part: `Unlock` checks the signature against the **datum's** owner, `Recover` against the **parameter**. + +Notice too that the redeemer finally matters. Earlier it had one choice, so it decided nothing. Here it picks which rule applies, and each choice needs a different signature. + +## Why a parameter changes the address + +A parameter is part of the contract's code, so it changes the compiled bytes, which changes the **hash**. And the hash is the **address**. One piece of source, two recovery keys, two separate vaults: + +```mermaid +flowchart LR + S["the recovery vault
one source file"] -->|"compile
once"| C["`the contract with a **blank** + where recovery goes + _no address yet_`"] + C -->|"fill it in:
recovery = aaaa…aa"| A["`one script hash + **addr_test1wrzptf…**`"] + C -->|"fill it in:
recovery = bbbb…bb"| B["`a different script hash + **addr_test1wpc707…**`"] +``` + +Those two addresses come from one file and two recovery keys, with nothing in common between them. This is not a side effect to work around: it is the reason to use a parameter at all. + +Anyone can read the recovery key straight out of the contract. That is fine, because it is a public key **hash**, the same kind of value the datum holds. It names *who* may recover, and naming somebody is not the same as being them: taking the funds still needs a **signature** from that key, and only its owner can produce one. + +Why not put the recovery key in the **datum** instead? Because then every vault would share one address, and each locked UTxO would carry its own recovery key, hidden inside until you opened it. Two UTxOs sitting side by side could have completely different backup keys and look identical. As a parameter, the key is part of the address, so a different key means a different address, and the address alone tells you which key can recover. + +**[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** left you a rule for choosing between the datum and the redeemer: the facts that must be kept go in the **datum**, and the action being taken goes in the **redeemer**. A parameter sits above both of them, and the question it answers is different: + +- **Parameter** for settings fixed when the contract is deployed, the same for every UTxO at that address: a recovery key, an oracle's address, a token policy. +- **Datum** for facts that differ from one locked UTxO to the next. + +Ask "is this the same for every UTxO at this address?" first. If yes, it is a parameter. Only if no do you go back to the datum or redeemer question. + +There is one more thing you could do, and it is worth knowing why it is worse. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. Same code, same tests, many vaults. + +:::warning A recovery key can spend the vault +`Recover` is a real spending path, so whoever holds the recovery key can take the funds. That is the point of a backup, and it is also the risk. Use a key **you** control, such as a hardware wallet kept somewhere else. Never a key belonging to somebody you would not hand the funds to today. +::: + +:::note The same shape is how contracts get an admin +`Recover` is one instance of a wider pattern: a named key with a path of its own, for the cases the main rule cannot cover. Contracts use it for a project key that alone may mint a collection's NFTs, for the single key allowed to update a price feed, which is the oracle you build in **modifying state**, and for an admin who can pause a protocol by updating a config UTxO that every other validator reads as a **reference input**. + +Where that key is named follows the rule above: a parameter when it is fixed for the whole deployment, the datum when it differs from one UTxO to the next, as the oracle's does. +::: + +## What "filling the blank" actually involves + +The three values travel by three different routes, and each is put in place by something different. The **datum** goes on the output when you lock. The **redeemer** goes in the spending transaction. The **parameter** is applied before either exists, to the compiled script itself, and that step is worth being precise about because it sounds heavier than it is. + +Filling the blank does not compile anything and does not ask the network for anything. Your off-chain code takes the compiled script from your blueprint (`plutus.json`), with the blank still in it, supplies the missing value, and hashes what comes out. Two lines of ordinary code, no deployment, no transaction, no announcement. **That is the whole of "deploying" a parameterized contract**, and you will write those two lines in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. + +You will meet the word "deploy" in one other sense, though. It also describes putting the script into a UTxO, so that later transactions point at it instead of carrying a copy of it. That one really is a transaction, and it is optional: a way to make every spend smaller, not a step you must take before a contract works. **Reference inputs & scripts** does it. + +One consequence lands right away, though, and it lands on the redeemer. This contract's `VaultAction` finally lists two choices, so its redeemers finally use both constructor numbers from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: `Unlock` is constructor 0 and `Recover` is constructor 1. Get those two the wrong way round later and the vault will look at the wrong key, without complaining. + +## Try it + +**Give your vault a backup key.** It is the contract you already have plus one parameter and one extra action, so most of it you have written already. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +Open `validators/vault.ak`. The redeemer changes first: `VaultAction` gains `Recover`, on the line after `Unlock`. The order matters, because it is what makes `Recover` constructor 1 off-chain, the `mConStr1([])` from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. `VaultDatum` does not move at all, the owner is still the one fact each locked UTxO carries. + + + {extractRegion(VaultAiken, "types")} + + +Then the validator itself. It takes the parameter in brackets after its name, `_redeemer` loses its underscore because the rule finally reads it, and the single line you wrote last lecture becomes a `when` with one branch per action. Both branches ask the same question: is this key among the signers? And differ only in which key they ask about: the datum's `owner` for `Unlock`, the parameter's `recovery` for `Recover`. + +Both branches ask `list.has`, so there is nothing new to import. + + + {extractRegion(VaultAiken, "vault", "mint-handler")} + + +```bash +aiken check +``` + +**It does not compile**, and the error is the lesson. Your two tests from **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** call `vault.spend` with four arguments, and the handler now takes five. A parameter always comes **first**, before the handler's own arguments, so every call has to gain a `recovery` in front: + +```aiken +// was +vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +// now +vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +``` + +Add the recovery key beside `owner` and `stranger`, and a test for each side of the new door: + + + {extractRegion(VaultAiken, "recover-tests")} + + +Fix the three existing calls the same way, then run `aiken check` again. Five tests, five passes. + +Look at what the last two bought you. `recover_ok_when_the_recovery_key_signs` is the obvious one. **`recover_fails_when_the_owner_signs` is the one that matters**: it asks whether the two doors are genuinely separate. A vault where the owner can also take the `Recover` path compiles exactly as happily as one where they cannot, and nothing but that test tells the two apart. + +Your vault now has two ways in: the owner's key for normal use, and the backup key for the day it is needed. And you know they are separate, rather than hoping. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +**Then recompile.** The contract changed shape, so the blueprint has to be rewritten: + +```bash +aiken build +``` + +Open `plutus.json` and look at the entry for `vault.vault.spend`. It has grown a `parameters` field naming the blank you left, and its `hash` is **not** the one from before you added the parameter. That is the section above made concrete: a different contract, so a different hash, so a different address. Nothing was deployed, and nothing was announced. The file changed, and that was the whole event. + +The blank itself is still empty. Filling it in is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does, and until something does, this contract has no address at all. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Parameterized scripts](/docs/developers/curriculum/smart-contracts/lock-and-spend#parameterized-scripts) — applying parameters from an SDK, with typed and untyped versions. +- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses) — how a script hash becomes an address in the first place. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — what belongs in a parameter, and what must never go anywhere public. + +Next: **[Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md new file mode 100644 index 0000000000..8ac509380d --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -0,0 +1,223 @@ +--- +title: "Validator purposes" +sidebar_label: "Validator purposes" +description: "One validator can guard different things — spending a UTxO, minting tokens, withdrawing rewards — depending on its purpose." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak"; + +# Validator purposes + +So far "validator" has meant _guarding a locked UTxO_. That is the most common job, but it is not the only one. A validator can guard several different kinds of action, and the kind it is guarding is called its **purpose**. The idea is the same. Only what starts it changes. + +These are the purposes you will meet: + +| Purpose | Runs when… | The question it's asked | +|---|---|---| +| **spend** | someone spends a UTxO locked at the script address | may this locked UTxO be spent? | +| **mint** | a transaction creates or burns tokens under the script's policy | may these tokens come into existence, or stop existing? | +| **withdraw** | staking rewards are withdrawn under the script | may these rewards be taken? | +| **publish / vote / propose** | certificates or governance actions are submitted | may this certificate or vote go through? | + +Every purpose works the same way. Something in a transaction touches your script, the network runs your validator, and it answers **yes or no**. Only the trigger and the thing being guarded change. You have already seen a simpler version of the mint purpose. The Beginner [minting example](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) used a **native script** as its policy. You use a validator instead when the rule needs to do more than check who signs and when. + +The handler you write changes a little between purposes, because the question changes. A **spend** handler is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** handler is not, because nothing is being unlocked, so there is no locked UTxO and no note. Instead it is told which policy is being minted under. All of them receive the redeemer and the whole transaction. Your vault uses **spend** today. In this lecture it gains **mint** as well. + +## One validator, many purposes, one hash + +Here is the powerful part. A **single validator** can handle **several purposes at once**, and it has exactly **one hash**. That one hash is all of these at the same time: + +- its **address** (for the _spend_ purpose), +- its **policy ID** (for the _mint_ purpose), +- its **stake credential** (for the _withdraw_ purpose). + +```mermaid +flowchart TD + S["your validator,
compiled"] -->|hash it| H["one script hash"] + H -->|written as an address| A["`**spend** + guards the UTxOs locked there`"] + H -->|written as a policy ID| P["`**mint** + guards tokens issued under it`"] + H -->|registered as a stake credential| W["`**withdraw** + guards reward withdrawals`"] +``` + +That is not three scripts. It is one compiled script doing three jobs. The hash **is** the script's identity, and where you put that hash decides which question the network asks it. Put it in an address and it guards funds. Put it on a token as the policy ID and it guards who may create that token. Register it as a stake credential and it guards rewards. + +The result is more useful than it first sounds. Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. + +## Your vault declares only one purpose, so far + +The vault you have been building handles only **spend**. Its source says so in two places: the spend handler you wrote, and the `else` block that **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** asked you to copy without explaining: + + + + +```aiken +else(_) { + fail +} +``` + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +That is what it has been doing all along: covering **every other purpose**. If anything tries to use this script as a minting policy, or a stake credential, or anything else besides spending, the answer is no. A script that allowed purposes you never thought about would be approving actions you never considered. Writing only a spend handler is not the same as making spending the only thing possible. You will see this pair, one real handler plus a refusing `else`, in most small contracts. + +## Minting and locking in one transaction + +The handler you are about to write guards a token of the vault's own, and it allows exactly two things: one token created, or one token destroyed. Nothing else under this policy. + +That is enough for something the vault could not do before. The script's hash is both the **policy id** that approves the token and the **address** the token is sent to, so one transaction can create the token and lock it in the vault at once. + +```mermaid +flowchart LR + W["your wallet
5 ADA"] --> T + + subgraph T["one transaction"] + direction TB + M["mint 1 VAULT
the mint handler runs"] --> L["build one output at
the vault's own address"] + end + + T --> V["UTxO at the vault
5 ADA + 1 VAULT + datum"] + V -->|"later: unlock
the spend handler runs"| B["your wallet
5 ADA + 1 VAULT"] +``` + +The token is created and locked in the **same** transaction, so it never stops at your wallet on the way in. It reaches you when you unlock, together with the ADA it was guarding. + +## Try it + +**Give your vault a second purpose: let it mint its own token.** The `else` block refuses minting today, so you replace it with a real `mint` handler. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +The helper this rule needs is already in the project. You added **vodka** in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** for its `mocktail` half, and this rule uses its other half. + +The three names are worth sorting out, because you now use both. **vodka** is the package. **cocktail** is its half for contracts, which is where `token_minted` comes from. **mocktail** is its half for tests, for building fake transactions. One package, two module names. + +The mint rule needs a type and that helper. In `validators/vault.ak`, add both at the top: + + + {`${extractRegion(VaultAiken, "import-policy-id")}\n${extractRegion(VaultAiken, "import-token-minted")}`} + + +`token_minted` does the work: it answers "does this transaction mint exactly this much of this token?". + +The mint rule needs a name to check against, so give the token one, above the validator: + + + {extractRegion(VaultAiken, "token-name")} + + +Now the rule for the token itself: one may be created, or one destroyed, and nothing else. Add the handler **inside the validator block**, between `spend` and `else`: + + + {extractRegion(VaultAiken, "mint-handler")} + + +Read the arguments, because they differ from `spend`. **No datum reaches this handler**: a datum belongs to the UTxO being unlocked, and minting unlocks nothing. The transaction can still attach a datum to an output it creates, and the one that mints a token and locks it does, but that note belongs to the new vault UTxO and the mint rule is never handed it. Instead the handler is told its own `policy_id`, which is this script's hash. The rule allows two things and nothing else: minting one token (`1`), or burning one (`-1`). + +```bash +aiken check +aiken build +``` + +Open `plutus.json` and look at the `validators` list. It now has **three** entries, `vault.vault.spend`, `vault.vault.mint` and `vault.vault.else`, and all three carry the **same hash**. One script, three doors. + +Compare that hash with ours: + +``` +778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf +``` + +If it matches, you wrote the same contract we did, byte for byte. Same hash means the same address **and** the same policy id. + +It is worth knowing what that hash is *not*. Your vault takes a parameter, so this is the script with the blank still in it, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. Filling the blank with a real recovery key gives a different hash, and that one is the address funds actually go to. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +:::note A different hash is not a failure +The hash is made from the **compiled code**, not from what the contract does. Two vaults can follow exactly the same rule and still hash differently, because there is usually more than one way to write the same check, and each one compiles to something slightly different. + +So the two things answer different questions. Your **tests** say the vault behaves correctly. The **hash** says you wrote it the same way we did. If yours passes the tests but misses the hash, nothing is wrong: it works, and it simply lives at a different address than ours. Only worry if the tests fail. +::: + +**And unlocking needs no change at all.** The spend handler still checks the owner's signature, exactly as it did before the vault could mint anything, and the token comes back with the ADA. + +Then match each action to the purpose the network would run: + +- Unlock vested funds after a deadline → **spend** +- Create a one-of-a-kind NFT → **mint** +- Claim your staking rewards → **withdraw** + +One script, three kinds of action. + +**Now prove the new handler.** A `mint` handler is a new rule, so it needs its own tests, and they are written exactly like the ones you already have. + + + + +Two more helpers from mocktail: `mint` builds a transaction that mints something, and `mock_script_hash` stands in for a policy id. They live in modules you already import, and Aiken takes them on their own lines, so add these rather than editing the lines you have: + + + {extractRegion(VaultAiken, "mint-test-imports")} + + +Then the tests, at the bottom of the file: + + + {extractRegion(VaultAiken, "mint-tests")} + + +Minting one passes, burning one passes, minting two is refused: exactly the rule you wrote. `Void` is the redeemer, because that handler ignores it, and `recovery` leads each call here too, because a parameter comes first in **every** handler, not just `spend`. + +```bash +aiken check +``` + +Eight tests, eight passes. Five of them are the spend rule from the last two lectures, still green, which is the other thing a test suite is for: you just added a whole new purpose to this contract and you know for certain you did not disturb the old one. + +**Then break the new rule.** Change the `1` in the first `or` branch to `2` and run `aiken check` again. `mint_ok_for_a_single_token` and `mint_fails_for_more_than_one` both go red together, which is worth a second look: one says the allowed case is now refused, the other says the forbidden case is now allowed. Put the `1` back. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## Your contract is finished + +That is the vault: a spend rule with two doors, a mint rule guarding its own token, one hash for all of it, and eight tests saying so. + +Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the other half of the track, and it is the whole off-chain side in one go: the address, the transactions that lock, unlock, recover and mint, the tests that drive them, and a page in a browser with buttons on it. It can be written straight through now, without stopping, precisely because the contract behind it has stopped moving. + +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## Go deeper + +- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator) — "one validator, many purposes, one hash," with real handlers. +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — the full purpose table. +- [Minting policies](/docs/developers/curriculum/native-tokens/minting-policies) — the mint purpose in depth, native and script policies side by side. +- [Staking](/docs/developers/curriculum/staking-governance/staking) — where stake credentials and the withdraw purpose fit in. + +Next: **[Off-chain and frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md new file mode 100644 index 0000000000..1fb71d95f8 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -0,0 +1,423 @@ +--- +title: "Off-chain and frontend integration" +sidebar_label: "Frontend integration" +description: "The other half of a contract: deriving its address, building the transactions that lock and unlock, proving them offline, and wiring the whole thing to a wallet in the browser." +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import CodeBlock from "@theme/CodeBlock"; +import extractRegion from "@site/src/utils/extractRegion"; +import Blueprint from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts"; +import Datum from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts"; +import LockLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts"; +import UnlockLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts"; +import FetchLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts"; +import MintLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts"; +import OfflineTests from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts"; +import Minimal from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx"; +import Proxy from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts"; + +# Off-chain and frontend integration + +Your contract is finished. It compiles, its eight tests pass, and it has a hash. And it can do nothing at all, because **a contract cannot act**. It only answers yes or no when something asks it to. + +That something is your app, and this lecture is the whole of it. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left `off-chain/` folder empty, everything on that side of the line arrives here, in one go. By the end you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. + +It arrives all at once for a reason. The contract is where the thinking is, and it changed with every lecture: a datum, a rule, a parameter, a second purpose. The off-chain half barely changes at all. It is the same few builders every time: derive the address, attach the datum, spend the UTxO. Writing them against a contract that has stopped moving is far easier than rewriting them six times as the contract grows. + +**You write all of it, and there is less than you think.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. Two more are the small backend that keeps your Blockfrost key out of the browser and the page itself. Nothing is downloaded, and every file is short enough to read. + +## The bridge: from blueprint to address + +The off-chain side starts from `plutus.json`, the file your compiler wrote. It holds the compiled validator. Filling in its parameter finishes the script, and hashing the finished script gives the **address**. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters#why-a-parameter-changes-the-address)** drew that chain and promised you the two lines of code at the end of it. You write them below, in the first file you create. + +Deriving the address is not a deployment. The address exists because the contract exists, so you could work it out on a computer that has never been online, and anyone with the same contract and the same parameter arrives at the same address. + +## Lock, then unlock + +Locking is an ordinary payment that happens to be addressed to a script, with the datum attached to the output, exactly as **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator#locking-is-just-a-payment)** described. **Unlocking is where the contract runs.** That transaction still carries everything a plain payment does, its inputs, outputs, fee, signatures and validity window, and it carries four things a plain payment never needs: + +- the **script** itself, because the network cannot run a program it has not been given. +- the **redeemer**, because the validator has to be told which action you are taking. +- a **required signer** entry, because the rule reads the signer list and this is what puts you on it. +- **collateral**, a deposit the network keeps if the script fails after passing its checks. + +The third of those is the one people most often forget. Your wallet signing a transaction is not the same as your key hash appearing in the transaction's required-signers field. That field is `extra_signatories`, the one your vault reads in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, and asking for it is a separate step from signing. Forget it and the signature is there but the validator cannot see it, so a correct contract refuses a legitimate spend. + +Minting adds nothing conceptually, because the same hash is both the address and the policy id, the identifier saying which script may create a token. That is **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** in practice. Minting does add collateral, because it runs a script, and a plain lock does not. + +## Collateral, and what a script costs + +Collateral is a deposit the network takes only when a script fails after passing structural checks. The rules are in **[fees](/docs/developers/curriculum/fundamentals/core-concepts/fees#collateral)** and the two-phase model behind them is in **[transaction failures](/docs/developers/curriculum/start-building/transaction-failures#the-two-phase-model)**. Three things about it are specific to what you are building: + +- It must hold **only ADA**, and it must sit at a **plain key address** with no script guarding it. Otherwise the network would need to run a second script just to collect the deposit. +- **In normal use it is never taken**, because the validator runs before you send anything. In the tests below that happens on your own machine. In the page, the job goes to the **provider**, the service that reads the chain for you, which is Blockfrost here: the builder is handed that same provider a second time, in the role of **evaluator**. +- This is the first project whose **code** reads the chain, which is why it needs a Blockfrost key when the Beginner track never did. The builder resolves inputs and fee settings through the provider, and the check before you send asks it to run your script as well. + +:::tip Set collateral once and forget it +In **[Lace](https://www.lace.io/)** this is a one-time setup that sets a few ADA aside. See the [Lace FAQ](https://www.lace.io/faq). The ADA is still yours and still counted in your balance, only reserved. Without it, every script spend you build fails before it leaves your machine, with a "no collateral" error. +::: + +Unlocking also costs more than locking, because it runs a program and that is priced separately in **[execution units](/docs/developers/curriculum/fundamentals/core-concepts/fees#script-execution-fees)**. Our vault is about as small as a contract can be, so here the difference is a fraction of a test ADA. The mechanism is the same at any size. + +## The browser half + +Every builder below ends the same way: it returns an **unsigned transaction**. Your app builds, the **wallet** signs and submits, and your code never sees a key. That division is [CIP-30](/docs/developers/curriculum/dapps/connect-a-wallet#what-cip-30-gives-you), the interface every Cardano wallet exposes to a page, which is why an app written for one wallet works with the rest. + +One detail about signing an unlock is worth knowing before you see it in code. The wallet signs **partially**: it signs the inputs it owns and leaves the rest alone. One of those inputs is the locked UTxO, and it sits at a script address, where no key can sign for anything. Whether it may be spent is the validator's decision, made when the network runs it. Ask the wallet for a complete signature instead and it refuses, because you are asking it to sign for something it has no key for. + +## The browser cannot keep a secret + +For the first half of this lecture your Blockfrost key sits in `.env`, and that is safe, because everything reading it runs on your own machine. A browser app is the opposite. Everything it needs in order to run has to be **sent to the person using it**, and anything sent can be read. There is no private part of a page, so a key written into that JavaScript is not hidden. It is published. + +Vite, the build tool that serves and bundles your page, draws that line for you: **your page can only read variables whose names start with `VITE_`, and whatever it reads is written into the files it ships.** Everything else in `.env` stays on your machine, where the backend can still read it, and never reaches the browser at all. That is why your key is never given the prefix, and why the network id is. + +So the key needs a second program, running where you control it: the **frontend** builds transactions and holds no secrets, and the **backend** holds the key and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. + +## The whole flow, both halves together + +```mermaid +sequenceDiagram + participant App as Your app
(the browser, no secrets) + participant Back as Your backend
(holds the Blockfrost key) + participant W as The wallet
(browser extension) + participant Net as Network + participant Vault as The vault's address
(no wallet, no keys, no owner) + App->>App: derive the address from the blueprint + App->>W: here is an unsigned payment of 5 ADA,
with a datum naming the owner + W->>Net: signed, submitted + Net->>Vault: payment valid, the 5 ADA now sits here + Note over Vault: the validator has not run yet + App->>Back: what is locked at that address? + Back->>Net: the same question, with the key attached + Net-->>App: one UTxO, and the datum on it + App->>App: build a spend of that UTxO: script,
redeemer, required signer, collateral + App->>Back: would this script pass, and what will it cost? + Back-->>App: yes, and here is its budget + App->>W: here is an unsigned spend + W->>Net: signed (partially), submitted + Net->>Net: run the validator: is the datum's owner
among the transaction's signers? + Net-->>W: yes + Vault->>App: the 5 ADA comes back +``` + +## Try it + +**Fill the other half of your workspace.** You have been inside `on-chain/vault/` since **[set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. From there, go up two levels to the workspace root, because everything below is about the other half: + +```bash +cd ../.. # from cardano-vault/on-chain/vault/ back to cardano-vault/ +``` + +That is the last folder change in the track. Every command from here runs from `cardano-vault/`. + + + + +### 1. The app project + +You need **[Node.js](https://nodejs.org/) 22.18 or newer**, because from that version it runs TypeScript files directly, with no build step. + +```bash +npm init -y +npm pkg set type=module +npm install @meshsdk/core@^1.9.1 @meshsdk/core-csl@^1.9.1 @meshsdk/wallet@^1.9.1 +mkdir off-chain/src off-chain/src/lib off-chain/server +``` + +The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser. + +Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed, so putting it at the root means every command in this track still runs from `cardano-vault/`. + +### 2. From blueprint to address + +The first file you write, and the bridge the top of this lecture describes. Create `off-chain/src/lib/blueprint.ts`: + + + {extractRegion(Blueprint, "file")} + + +Four things in it are worth reading slowly: + +- **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves touch, and it is a file, not a network call. +- **The title** `vault.vault.spend` is `..`, so it names your `vault.ak`, its `vault` validator, and its spend handler. +- **`applyParamsToScript`** fills the blank from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. These are the two lines that lecture promised you. +- **`RECOVERY`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out, but whatever you choose has to stay the same forever. + +:::caution Changing RECOVERY moves the vault +It is part of the script, so it is part of the hash, so it is part of the address. Lock funds with one value, change a single character, and your app will look for them somewhere else entirely and find nothing. The funds are not lost, they are at the old address, but you would have to put the old value back to reach them. +::: + +### 3. The datum and the redeemers + +The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, now built from the other side. Create `off-chain/src/lib/datum.ts`: + + + {extractRegion(Datum, "file")} + + +`mConStr0` is the numbered-constructor encoding that lecture described. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `Recover`, because it is declared second in `VaultAction`. Get those last two the wrong way round and nothing announces it: the vault reads the other branch and checks the other key. + +### 4. The four transactions + +These are the whole off-chain half: lock funds, find them again, unlock them, and mint the vault's own token. First `off-chain/src/lib/lock.ts`: + + + {extractRegion(LockLib, "file")} + + +An ordinary payment, with two additions. `deserializeAddress(...).pubKeyHash` pulls your key hash out of your address, which is what goes in the datum, and `.txOutInlineDatumValue(...)` attaches that datum to the output. No script, no collateral, no redeemer: the contract does not run when you lock. + +Notice the wallet argument. It is typed as `IWallet`, the interface Mesh defines, and nothing here names a particular wallet. That is why the same file works with the seed-phrase wallet your tests use in step 5 and with the browser extension your page uses in step 7. + +Then `off-chain/src/lib/unlock.ts`, which is where the contract does run: + + + {extractRegion(UnlockLib, "file")} + + +Every extra line here is one item in that list. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. + +One argument is worth stopping on, because the next step is built on it. Passing an **evaluator** makes the builder run your **real compiled validator** before it returns anything. A spend the contract would refuse fails here, immediately, instead of on the chain where it would cost you the collateral. + +Next `off-chain/src/lib/fetch.ts`, because you cannot unlock what you cannot find. A script address is an ordinary address, so this is the same [UTxO query](/docs/developers/curriculum/start-building/query-the-chain#datums) you have made since Beginner: + + + {extractRegion(FetchLib, "file")} + + +Read the filter, because it is the point. **The vault's address is not yours.** It belongs to nobody, and anyone who compiled the same contract with the same parameter arrives at the same address, so what sits there is everyone's UTxOs mixed together. The only thing that says which are yours is the `owner` in each datum, which is exactly what your validator will check later. + +Last `off-chain/src/lib/mint.ts`, which is `lock.ts` plus the mint from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**, in one transaction: + + + {extractRegion(MintLib, "file")} + + +`vaultPolicyId()` hashes the same compiled script that gives you the address. One value, two roles, the whole point of that lecture, now in code. `.mint("1", ...)` creates exactly one token, which is precisely what your handler allows. + +### 5. Prove it offline + +Nothing has run yet. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** named a third level of testing it could not reach, because there was no app to test. There is now, and it needs no wallet, no test ADA and no network. + +Create `off-chain/src/vault.test.ts`. The imports first: + + + {extractRegion(OfflineTests, "offline-imports")} + + +Then a pretend chain and a wallet to go with it. `OfflineFetcher` is an in-memory chain you fill in yourself, and `MeshWallet` is a wallet built from a seed phrase rather than an extension: + + + {extractRegion(OfflineTests, "offline-setup")} + + +Then a few helpers for putting UTxOs on that chain. A real chain hands you a transaction hash; here you invent one, because nothing was ever submitted: + + + {extractRegion(OfflineTests, "offline-helpers")} + + +Now the first test. Locking runs no contract, so this one only has to build: + + + {extractRegion(OfflineTests, "offline-lock")} + + +And the second, which is the one that matters. It calls the very same `buildUnlockTx` your page will call, then evaluates it, which runs your **real compiled validator**. Getting an execution budget back means the contract said yes: + + + {extractRegion(OfflineTests, "offline-unlock")} + + +Run it: + +```bash +node --test off-chain/src/vault.test.ts +``` + +Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly, which is why step 1 asked for 22.18 or newer. + +Expect some extra output above that result, including a warning that cost models fell back to defaults. That is the offline chain saying it has no real protocol parameters, the network's current fee and size settings, to hand out. Read the `pass` and `fail` counts at the bottom, not the messages above them. + +**Now break the off-chain side, and watch which layer notices.** In `off-chain/src/lib/unlock.ts`, delete the `.requiredSignerHash(owner)` line and save. + +Run `aiken check on-chain/vault` first, passing the project folder now that you are one level above it. All eight contract tests still pass, because the contract is still correct. Nothing is wrong with the rule. + +Then run the test file again. It fails, in the same few milliseconds, and the evaluator reports which script did the refusing: + +``` +"tag":"spend","errorMessage":"the validator crashed / exited prematurely" +``` + +That `"tag":"spend"` says the refusal came from the spend validator, not from a transaction that failed to build. The gap between a correct contract and an app that builds the wrong transaction is invisible to a contract test, and this is exactly what catches it. Finding it took milliseconds and no test ADA. Finding it on the network would have meant locking real funds first and waiting for two confirmations. + +Put the line back and run it once more to be sure. + +### 6. The backend that keeps your key + +Everything so far ran on your machine and nowhere else. A page is different: everything it needs is sent to whoever opens it. So before writing the page, write the half that holds the key. + +First a `.env` file at the top of `cardano-vault/`, beside `package.json`, so no key is ever written into your code: + +```bash title=".env" +BLOCKFROST_API_KEY=previewYourKeyHere +VITE_NETWORK_ID=0 +``` + +- `BLOCKFROST_API_KEY` your Preview **project id**, from your project's page on [blockfrost.io](https://blockfrost.io/). It starts with `preview`. +- `VITE_NETWORK_ID` `0` for a test network, which is Preview here, and `1` for mainnet. This is the one that carries the `VITE_` prefix, for the reason the section above gives: the page needs it, and it is not a secret. + +Nothing in this track puts `cardano-vault/` into version control, but the day you do, add `.env` to a `.gitignore` **before** the first commit. A key in a commit is a key you have given away, even if you delete it in the next one. + +Now the backend itself. Create `off-chain/server/blockfrost.ts`: + + + {extractRegion(Proxy, "file")} + + +It is a relay and nothing more. It reads the key once, forwards whatever arrives to Blockfrost with the key attached, and hands the answer back untouched. The body is passed along rather than parsed, because reading the chain is a `GET`, evaluating a script is a `POST` carrying JSON, and submitting carries raw CBOR, the binary encoding a signed transaction travels in, and a `Buffer` passes all three through unharmed. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both halves. + +:::note What "backend" means here, exactly +`handleBlockfrost` is an ordinary Node request handler and knows nothing about Vite. Mounting it on Vite's server is a convenience, so `npm run dev` starts the front and the back together. Putting this on the internet means running that same function in a server you deploy. The handler does not change; only what hosts it does. +::: + +### 7. The page, and run it + +The last piece: a browser, a wallet and a user. + +```bash +npm install react react-dom +npm install -D vite @vitejs/plugin-react typescript vite-plugin-node-polyfills @types/react @types/react-dom +npm pkg set scripts.dev=vite +npm pkg set scripts.build="vite build" +``` + +`vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are for your editor rather than for any command here. `vite-plugin-node-polyfills` is the surprising one: Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have, so they have to be supplied. + +Two small files Vite needs, and they are the only ones whose paths depend on where things sit in your workspace. `index.html` goes at the top of `cardano-vault/`, beside `package.json`, because Vite serves the folder you run it from: + +```html title="index.html" + + + + + My vault + + +
+ + + +``` + +And `vite.config.ts` beside it, mounting the backend you wrote in step 6: + +```ts title="vite.config.ts" +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import { blockfrostProxy } from "./off-chain/server/blockfrost.ts"; + +export default defineConfig({ + plugins: [ + react(), + nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), + blockfrostProxy(), + ], +}); +``` + +**And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one. That is the whole swap, and it is why those builders were typed against the interface Mesh defines rather than against a particular wallet. + +So the last file you write is the page. Create `off-chain/src/app.tsx`: + + + {extractRegion(Minimal, "file")} + + +Look at the provider line first, because it is the entire client-side cost of having a backend: + +```ts +const provider = new BlockfrostProvider("/api/blockfrost"); +``` + +No key, and no change anywhere else. Mesh supports this directly: hand `BlockfrostProvider` a path instead of a project id and it treats it as a privately hosted Blockfrost, which is exactly what yours now is. Nothing in `off-chain/src/lib/` had to move for that, which is why those builders take a `provider` instead of creating one of their own. + +Three more things in it are the browser half from above, in code: + +- `BrowserWallet.enable("lace")` is the permission handshake. Swapping `"lace"` for another wallet id is the only change another wallet needs. +- `wallet.signTx(unsignedTx, true)` is the **partial** signature. Drop that `true` on the unlock and the wallet refuses, because you are asking it to sign a script input it holds no key for. +- `buildUnlockTx(wallet, provider, utxo, provider)` passes `provider` twice on purpose. The first is the **fetcher**, for looking things up; the second is the **evaluator**, which runs your contract before you send it. + +Start it: + +```bash +npm run dev +``` + +Open the printed URL **in the browser where Lace is installed**, with Lace set to Preview and collateral already set. Then, in order: + +1. **Connect wallet.** The extension asks for permission once. +2. **Lock 5 ADA.** Approve it. This is the plain payment: no contract runs. +3. **Refresh locked** after a few seconds, and your UTxO appears. That is your ADA sitting at an address nobody owns. +4. **Unlock.** This one runs your validator. The funds come back. +5. **Mint & lock 5 ADA.** The same lock, plus a VAULT token created under the contract's own policy, in one transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. + +If the page loads but **Lock** fails, look at `.env` before anything else. A Preview key starts with `preview`, and a mainnet or mistyped key shows up as a 401 on `/api/blockfrost/…` in the browser's **Network** tab. + +**Then prove the key is gone.** Open the developer tools, go to the **Network** tab, and press **Refresh locked**. Every request goes to `/api/blockfrost/…` on your own origin, and none to `blockfrost.io`. The browser is not talking to the provider at all. It cannot, because it has nothing to authenticate with. + +Now check the code that goes to the browser, which is the part that would have been public: + +```bash +npm run build +``` + +Then look in what it produced for the exact key your `.env` holds: + +```bash +grep -rF "$(grep BLOCKFROST_API_KEY .env | cut -d= -f2)" dist/ || echo "not there" +``` + +It is not there. Without the backend it would have been, sitting in `dist/assets/index-*.js`, where anyone who opened your page could have read it. Search for the bare word `preview` instead and you will get hits, but those are Mesh's own network names, not your key. + +**And notice which rules applied where.** Your backend reads the key straight out of `.env` and that is correct: it runs on your machine, for you. The page goes to anyone who opens it, so it gets none of it. Same key, same file, trusted in one place and not in the other, and the only thing that decides which rules apply is **where the code runs**. + +**Then break it on purpose, one last time.** You already watched the offline tests catch a missing `.requiredSignerHash(owner)`. Delete that line again and press **Unlock** here. Nothing reaches the chain: the check before sending, where your backend asks Blockfrost to run the script, already said no. The owner's key was never in `extra_signatories`, so `list.has` was false. Same refusal, same rule, now with a wallet connected and real test ADA at stake. Put the line back. + +
+ + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. The idea is identical, only the library calls differ. + + +
+ +Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. + +## That is the vault, finished + +You started with an empty folder. You now have a contract you wrote and tested, with two purposes under one hash, and an app that locks, mints and unlocks real test ADA through it. + +Look back at what each half cost. The contract took six lectures, because every one of them changed what the rule was. The app took one, because there was only ever one shape to it: derive the address, attach the datum, spend the UTxO, hand it to a wallet. That difference is not an accident of this example. It is the normal shape of Cardano work, and it is why the rest of this track goes straight back to contracts. + +Each of the remaining lectures is the same shape with a different rule in the middle. The contracts arrive finished, and each lecture has you break one and write the missing rule back: + +- **Handling time** — funds that cannot move before a date. +- **Multi validators** — a token that acts as a key, where burning it is what opens the lock. +- **Modifying state** — data that is updated instead of released. +- **Reference inputs & scripts** — one contract reading another's data. + +## Go deeper + +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — the same two transactions, using more of what the SDK offers. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — providers, and reading datums back out. +- [Use a provider](/docs/developers/curriculum/production/use-a-provider) — keys, quotas and what to do when one goes down. +- [Offline testing](/docs/developers/curriculum/start-building/offline-testing) — mocking the chain and evaluating budgets without a node. +- [Connect a wallet](/docs/developers/curriculum/dapps/connect-a-wallet) — CIP-30 in full, and the backend-builds pattern this lecture starts. +- [Going to production](/docs/developers/curriculum/production/going-to-production) — the rest of the checklist this is one line of. +- [Optimization](/docs/developers/curriculum/smart-contracts/advanced/optimization) — keeping execution units, and therefore fees, down. + +Next: **Handling time: vesting**. diff --git a/docs/developers/onboarding/lectures/intermediate/introduction.md b/docs/developers/onboarding/lectures/intermediate/introduction.md index 4f8d66381b..681639d18c 100644 --- a/docs/developers/onboarding/lectures/intermediate/introduction.md +++ b/docs/developers/onboarding/lectures/intermediate/introduction.md @@ -1,5 +1,150 @@ --- -title: "Intermediate" +title: "Intermediate: smart contracts" sidebar_label: "Introduction" -description: "The Intermediate track of the onboarding path." +description: "Smart contracts from scratch — on-chain vs off-chain, validators, datum and redeemer, the tools to write and run them, then vesting, gift cards, oracles and testing." --- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Intermediate: smart contracts + +You finished Beginner, so you can move value around Cardano. This track makes the chain **enforce rules** about how that value moves. That is what a smart contract is. We build up from the ideas to a real contract you compile and run yourself. + +:::note Coming from Ethereum? +"Smart contract" means something different here. On Cardano it is not a deployed program with storage that you call and that then acts. It is a **rule that answers yes or no** to a transaction your app already built. State still exists, but it lives in the **datum** on a UTxO rather than inside the contract. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers the rest. This track builds the model from scratch anyway. +::: + +## What you'll be able to do + +After this track you'll be able to: + +- Split any Cardano app into two halves: the part your code prepares, and the part the chain enforces. +- Read a validator and say what it lets through and what it rejects. +- Choose what goes in a datum and what goes in a redeemer, and explain why. +- Write a validator, compile it, and get a script address out of the blueprint. +- Build the transactions that lock funds at that address and unlock them again, collateral included. +- Run a contract you wrote end to end on the test network, and watch it refuse a spend that breaks its rule. +- Put a deadline on funds, mint a token from a contract, and change data that's already on the chain. +- Publish a contract once instead of carrying it in every transaction, and let one contract read another's data without consuming it. +- Test a contract properly before it ever holds anything real. + +## The lectures + +1. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** — the two halves: your app prepares and the chain enforces. +2. **[Set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)** — a language and compiler, and the contract project the next six lectures fill. +3. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** — a yes/no gatekeeper that guards a locked UTxO. +4. **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** — the data you hand a contract. +5. **[The transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)** — everything else a contract can look at before it decides. +6. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** — tracing, unit tests and property-based tests, so the next two lectures can change the contract safely. +7. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** — a value built into the contract itself, before it has an address. +8. **[Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** — spend and mint, under one script hash. + +Those eight are the contract, and nothing after them changes it. The ninth is the other half, all at once: + +9. **[Off-chain and frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** — derive the address, build every transaction, prove them offline, then connect a wallet and drive the vault from a page in the browser. + +The last four are what you build with the machine: + +10. **Handling time: vesting** — funds that can't move before a date, enforced without the contract ever reading a clock. +11. **Multi validators: a gift card** — one script guarding two different actions at once, and minting from a contract. +12. **Modifying state: an oracle** — changing data that's already on the chain. +13. **Reference inputs & reference scripts** — publish a contract once, and let one contract read another's data without consuming it. + +## You write the vault, one lecture at a time + +Lectures 1 to 9 build **one worked example**: a vault that locks funds and only releases them to the owner who signs. You do not read it, you write it. + +**The contract comes first, and it comes alone.** Lectures 2 to 8 are on-chain only: you write the validator, compile it, test it and finish it, with no app yet. The whole off-chain half is lecture 9. That is deliberate. The contract is where the thinking is, and it changes with every idea in the track, while the app that drives it is nearly the same code every time. + +Lectures 1 and 2 set up your workspace, `cardano-vault/`, with a half for each side, and leave you inside the contract project at `on-chain/vault/`. You stay there through lecture 8, so every Aiken command is the short kind: `aiken check`, `aiken add`, `aiken build`, with no paths to get wrong. Lecture 9 steps back up to the root, and that is the last folder change in the track. In between, each lecture explains one idea and has you add it to your contract: a validator that says yes to everybody, then the datum and redeemer, then the rule itself, then tests to hold it still, then a backup key, and finally a **mint** purpose so the vault can create its own token. + +Each step is a few lines and one command, and each one ends with a clean `aiken check`. From **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** onwards it also ends with a passing test suite, which is what makes the two lectures after it safe: both change a contract that already works. + +The four lectures after that work differently. Those contracts arrive finished, and the exercises have you break one and write the missing rule back. + +## What you need + +Four things. If you finished Beginner, the first is already done. + +- **A wallet on the test network.** **[Lace](https://www.lace.io/)** on **Preview**, with a little test ADA, [same as in Beginner](/docs/developers/onboarding/lectures/beginner/wallets-keys-addresses). +- **Collateral set aside in that wallet.** Collateral is a deposit the network only takes if a script fails unexpectedly. It is a one-time setup in the wallet, and **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** explains what it is for. In Lace, see the [Lace FAQ](https://www.lace.io/faq). +- **A provider key.** Your app now has to read UTxOs that are not in your wallet, the ones sitting at a contract's address, and work out what running a validator will cost before it sends anything. A wallet cannot do either, so you need a **[provider](/docs/developers/onboarding/lectures/beginner/providers-and-explorers)**. Get a free **[Blockfrost](https://blockfrost.io/)** Preview key. +- **A compiler for contracts.** You write the vault from lecture 3 onwards, so you need the toolchain for the language you pick: + + + + +Install it from the **[Aiken installation guide](https://aiken-lang.org/installation-instructions)**. It takes about a minute. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## The playground {#the-playground} + +Everything in these lectures is also finished and working in one example project, which we call the **playground**. It has every contract in the track, plus a small browser app that drives them: connect a wallet, mint and lock funds, unlock them again, put a deadline on funds, update an oracle. + +You do not need it to follow the lectures. It is here for two reasons: + +- **To see where you are going.** Run it once now, and the rest of the track is you rebuilding the first part of it yourself. +- **To get unstuck.** Every exercise says the same thing at the end: the finished code is here. + +Download it, and start the app: + +```bash +npx giget@latest gh:cardano-foundation/developer-portal/examples/onboarding/lectures/intermediate playground +``` + + + + +```bash +cd playground/vault/off-chain/mesh +npm install +cp .env.example .env # then paste your Blockfrost Preview key into it +npm run dev +``` + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. The idea is identical, only the library calls differ. + + + + +Open the printed URL **in the browser where Lace is installed**. Connect, set up collateral, then **Lock 5 ADA** and **Unlock** it again. The **Mint & lock** button does the same thing but also creates a token under the contract's own policy, which is what **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is about. + +Inside the folder, one directory per contract, and the code you read in these lectures is imported straight from it: + +``` +playground/ +├── vault/ the contract you are about to write lectures 3-9, 13 +│ ├── on-chain/aiken/ +│ └── off-chain/mesh/ +├── vesting/ handling time lecture 10 +│ ├── on-chain/aiken/ +│ └── off-chain/mesh/ +├── giftcard/ multi validators lecture 11 +│ └── on-chain/aiken/ +└── oracle/ modifying state, reference inputs lectures 12-13 + ├── on-chain/aiken/ + └── off-chain/mesh/ +``` + +**Each folder is a project in its own right.** Its contract and the app that drives it sit side by side, and nothing in it reaches into a sibling, so you can open one, run it, and take it apart without the other three in your way. + +The cost of that separation is that every app is separately installed and separately configured. Each `off-chain/mesh/` wants its own `npm install`, its own `.env`, and its own wallet connection. The `.env.example` files are identical, so once you have filled one in you can copy it across: + +```bash +cp vault/off-chain/mesh/.env vesting/off-chain/mesh/.env +``` + +Lectures 10 to 13 work directly in these folders, with `playground/` as the folder you run from: a different workspace, named on every command. Lectures 1 to 9 do not: there you build your own, and `playground/vault/` is the answer sheet. + +Ready? Start with **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**. diff --git a/docs/developers/onboarding/lectures/intermediate/lecture-1.md b/docs/developers/onboarding/lectures/intermediate/lecture-1.md deleted file mode 100644 index 932aed8670..0000000000 --- a/docs/developers/onboarding/lectures/intermediate/lecture-1.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Lecture 1" -sidebar_label: "Lecture 1" -description: "Intermediate — Lecture 1." ---- diff --git a/docs/developers/onboarding/lectures/introduction.md b/docs/developers/onboarding/lectures/introduction.md index 0113ef2845..42c2854e08 100644 --- a/docs/developers/onboarding/lectures/introduction.md +++ b/docs/developers/onboarding/lectures/introduction.md @@ -21,6 +21,8 @@ The core ideas you need before building anything: wallets, UTxOs, transactions, Smart contracts from scratch: their structure, their role, on-chain vs off-chain, what a validator is, datum and redeemer, the languages you write them in, and the whole development cycle. After this, you'll be able to **write and understand** smart contracts in Cardano. +**[Start the Intermediate module](/docs/developers/onboarding/lectures/intermediate/introduction)** + ### Advanced: Production-ready smart contracts Going from "it works" to "it's safe and scalable": common vulnerabilities, design patterns, optimization, and getting to production. After this, you'll be able to write **secure, scalable, high-quality** contracts. diff --git a/sidebars.js b/sidebars.js index d28a0c3c62..ad96c4cb5c 100644 --- a/sidebars.js +++ b/sidebars.js @@ -565,7 +565,15 @@ module.exports = { id: "developers/onboarding/lectures/intermediate/introduction", }, items: [ - "developers/onboarding/lectures/intermediate/lecture-1", + "developers/onboarding/lectures/intermediate/on-chain-vs-off-chain", + "developers/onboarding/lectures/intermediate/tools", + "developers/onboarding/lectures/intermediate/what-is-a-validator", + "developers/onboarding/lectures/intermediate/datum-and-redeemer", + "developers/onboarding/lectures/intermediate/transaction-context", + "developers/onboarding/lectures/intermediate/testing", + "developers/onboarding/lectures/intermediate/parameters", + "developers/onboarding/lectures/intermediate/validator-purposes", + "developers/onboarding/lectures/intermediate/frontend-integration", ], }, { From 3943444427a61e33d0ec407a0672d9d173f0002e Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 13:21:02 -0300 Subject: [PATCH 05/89] Replace the Blockfrost handler with a Vite proxy rule --- .../intermediate/9-frontend-integration.md | 80 +++++++++-------- .../vault/off-chain/mesh/server/blockfrost.ts | 90 ------------------- .../vault/off-chain/mesh/vite.config.ts | 55 ++++++++---- 3 files changed, 78 insertions(+), 147 deletions(-) delete mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 1fb71d95f8..66eb98d22f 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -16,7 +16,6 @@ import FetchLib from "!!raw-loader!@site/examples/onboarding/lectures/intermedia import MintLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts"; import OfflineTests from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts"; import Minimal from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx"; -import Proxy from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts"; # Off-chain and frontend integration @@ -26,7 +25,7 @@ That something is your app, and this lecture is the whole of it. **[On-chain vs It arrives all at once for a reason. The contract is where the thinking is, and it changed with every lecture: a datum, a rule, a parameter, a second purpose. The off-chain half barely changes at all. It is the same few builders every time: derive the address, attach the datum, spend the UTxO. Writing them against a contract that has stopped moving is far easier than rewriting them six times as the contract grows. -**You write all of it, and there is less than you think.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. Two more are the small backend that keeps your Blockfrost key out of the browser and the page itself. Nothing is downloaded, and every file is short enough to read. +**You write all of it, and there is less than you think.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove the whole thing before a wallet is ever connected. Nothing is downloaded, and every file is short enough to read. ## The bridge: from blueprint to address @@ -73,14 +72,14 @@ For the first half of this lecture your Blockfrost key sits in `.env`, and that Vite, the build tool that serves and bundles your page, draws that line for you: **your page can only read variables whose names start with `VITE_`, and whatever it reads is written into the files it ships.** Everything else in `.env` stays on your machine, where the backend can still read it, and never reaches the browser at all. That is why your key is never given the prefix, and why the network id is. -So the key needs a second program, running where you control it: the **frontend** builds transactions and holds no secrets, and the **backend** holds the key and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. +So the key has to live somewhere the browser never reaches: your **page** builds transactions and holds no secrets, and a small **proxy**, running on a machine you control, holds the key and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. ## The whole flow, both halves together ```mermaid sequenceDiagram participant App as Your app
(the browser, no secrets) - participant Back as Your backend
(holds the Blockfrost key) + participant Back as Your proxy
(holds the Blockfrost key) participant W as The wallet
(browser extension) participant Net as Network participant Vault as The vault's address
(no wallet, no keys, no owner) @@ -123,7 +122,7 @@ You need **[Node.js](https://nodejs.org/) 22.18 or newer**, because from that ve npm init -y npm pkg set type=module npm install @meshsdk/core@^1.9.1 @meshsdk/core-csl@^1.9.1 @meshsdk/wallet@^1.9.1 -mkdir off-chain/src off-chain/src/lib off-chain/server +mkdir off-chain/src off-chain/src/lib ``` The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser. @@ -255,9 +254,9 @@ That `"tag":"spend"` says the refusal came from the spend validator, not from a Put the line back and run it once more to be sure. -### 6. The backend that keeps your key +### 6. The key, and where it lives -Everything so far ran on your machine and nowhere else. A page is different: everything it needs is sent to whoever opens it. So before writing the page, write the half that holds the key. +Everything so far ran on your machine and nowhere else. A page is different: everything it needs is sent to whoever opens it. So the key gets its own file, which the page never reads. First a `.env` file at the top of `cardano-vault/`, beside `package.json`, so no key is ever written into your code: @@ -271,17 +270,7 @@ VITE_NETWORK_ID=0 Nothing in this track puts `cardano-vault/` into version control, but the day you do, add `.env` to a `.gitignore` **before** the first commit. A key in a commit is a key you have given away, even if you delete it in the next one. -Now the backend itself. Create `off-chain/server/blockfrost.ts`: - - - {extractRegion(Proxy, "file")} - - -It is a relay and nothing more. It reads the key once, forwards whatever arrives to Blockfrost with the key attached, and hands the answer back untouched. The body is passed along rather than parsed, because reading the chain is a `GET`, evaluating a script is a `POST` carrying JSON, and submitting carries raw CBOR, the binary encoding a signed transaction travels in, and a `Buffer` passes all three through unharmed. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both halves. - -:::note What "backend" means here, exactly -`handleBlockfrost` is an ordinary Node request handler and knows nothing about Vite. Mounting it on Vite's server is a convenience, so `npm run dev` starts the front and the back together. Putting this on the internet means running that same function in a server you deploy. The handler does not change; only what hosts it does. -::: +Nothing reads that key in the browser. What reads it is a **proxy**: a rule that catches every call your page makes to `/api/blockfrost/…`, adds the key, and passes the call on to Blockfrost. Your page therefore only ever talks to its own origin. You write that rule in the next step, because it lives in the same file that configures the page. ### 7. The page, and run it @@ -312,23 +301,44 @@ Two small files Vite needs, and they are the only ones whose paths depend on whe ``` -And `vite.config.ts` beside it, mounting the backend you wrote in step 6: +And `vite.config.ts` beside it, which carries the proxy rule from the step before: ```ts title="vite.config.ts" -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { nodePolyfills } from "vite-plugin-node-polyfills"; -import { blockfrostProxy } from "./off-chain/server/blockfrost.ts"; - -export default defineConfig({ - plugins: [ - react(), - nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), - blockfrostProxy(), - ], + +export default defineConfig(({ mode }) => { + // Read `.env` here, in Node. Nothing in this file reaches the browser. + const env = loadEnv(mode, process.cwd(), ""); + const key = env.BLOCKFROST_API_KEY ?? ""; + + const proxy = { + "/api/blockfrost": { + target: `https://cardano-${key.slice(0, 7)}.blockfrost.io/api/v0`, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/blockfrost/, ""), + headers: { project_id: key }, + }, + }; + + return { + plugins: [ + react(), + nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), + ], + server: { proxy }, + preview: { proxy }, + }; }); ``` +Four lines do the work. `target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both halves. + +:::note This proxy runs with the dev server, not on the internet +`server.proxy` applies to `npm run dev` and `preview.proxy` to `npm run preview`, which is everything this lecture needs. A deployed site has no Vite, so hosting this page for real means giving your host the same rule: a redirect on Netlify or Vercel, a `location` block in nginx, or a small server of your own. What must stay true is the shape: the browser calls your origin, and something you control adds the key. +::: + **And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one. That is the whole swap, and it is why those builders were typed against the interface Mesh defines rather than against a particular wallet. So the last file you write is the page. Create `off-chain/src/app.tsx`: @@ -337,7 +347,7 @@ So the last file you write is the page. Create `off-chain/src/app.tsx`: {extractRegion(Minimal, "file")} -Look at the provider line first, because it is the entire client-side cost of having a backend: +Look at the provider line first, because it is the entire client-side cost of keeping the key out of the browser: ```ts const provider = new BlockfrostProvider("/api/blockfrost"); @@ -375,17 +385,11 @@ Now check the code that goes to the browser, which is the part that would have b npm run build ``` -Then look in what it produced for the exact key your `.env` holds: - -```bash -grep -rF "$(grep BLOCKFROST_API_KEY .env | cut -d= -f2)" dist/ || echo "not there" -``` - -It is not there. Without the backend it would have been, sitting in `dist/assets/index-*.js`, where anyone who opened your page could have read it. Search for the bare word `preview` instead and you will get hits, but those are Mesh's own network names, not your key. +Then search `dist/` for your key. It is not there. Without the proxy it would have been, sitting in `dist/assets/index-*.js`, where anyone who opened your page could have read it. Search for the bare word `preview` instead and you will get hits, but those are Mesh's own network names, not your key. -**And notice which rules applied where.** Your backend reads the key straight out of `.env` and that is correct: it runs on your machine, for you. The page goes to anyone who opens it, so it gets none of it. Same key, same file, trusted in one place and not in the other, and the only thing that decides which rules apply is **where the code runs**. +**And notice which rules applied where.** Your proxy reads the key straight out of `.env` and that is correct: it runs on your machine, for you. The page goes to anyone who opens it, so it gets none of it. Same key, same file, trusted in one place and not in the other, and the only thing that decides which rules apply is **where the code runs**. -**Then break it on purpose, one last time.** You already watched the offline tests catch a missing `.requiredSignerHash(owner)`. Delete that line again and press **Unlock** here. Nothing reaches the chain: the check before sending, where your backend asks Blockfrost to run the script, already said no. The owner's key was never in `extra_signatories`, so `list.has` was false. Same refusal, same rule, now with a wallet connected and real test ADA at stake. Put the line back. +**Then break it on purpose, one last time.** You already watched the offline tests catch a missing `.requiredSignerHash(owner)`. Delete that line again and press **Unlock** here. Nothing reaches the chain: the check before sending, where your proxy asks Blockfrost to run the script, already said no. The owner's key was never in `extra_signatories`, so `list.has` was false. Same refusal, same rule, now with a wallet connected and real test ADA at stake. Put the line back. diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts deleted file mode 100644 index d021ccab58..0000000000 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/server/blockfrost.ts +++ /dev/null @@ -1,90 +0,0 @@ -// #region file -/// The backend half of the app, and the only place the Blockfrost key exists. -/// -/// The browser cannot keep a secret: everything Vite ships is readable by -/// whoever opens the page. So the browser never gets the key. It asks this -/// handler instead, and this handler, running on a machine you control, adds -/// the key and forwards the question to Blockfrost. -/// -/// It is a relay, nothing more. It does not build transactions and does not -/// sign anything; the browser still does both. -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { Plugin } from "vite"; - -// #region handler -// Read `.env` into process.env. The same line the Node scripts use, this file -// is a Node script too, it just happens to be one that answers HTTP. -try { - process.loadEnvFile(); -} catch { - // No .env yet. The check below gives a better message than a crash on boot. -} - -const KEY = process.env.BLOCKFROST_API_KEY ?? ""; - -// A Blockfrost key names its own network: `preview...`, `preprod...`, `mainnet...`. -// Mesh reads the prefix the same way, so one variable configures both halves. -const NETWORK = KEY.slice(0, 7); -const BLOCKFROST = `https://cardano-${NETWORK}.blockfrost.io/api/v0`; - -/// Forward one request to Blockfrost with the key attached, and hand the answer -/// back untouched. `req.url` is whatever followed `/api/blockfrost`, so -/// `/addresses/addr_test1.../utxos` arrives here exactly as Mesh asked for it. -export async function handleBlockfrost(req: IncomingMessage, res: ServerResponse): Promise { - if (!KEY) { - res.statusCode = 500; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ error: "BLOCKFROST_API_KEY is not set in .env" })); - return; - } - - // Buffer the body rather than parse it: the evaluate endpoint sends JSON and - // the submit endpoint sends raw CBOR, and a Buffer carries both unharmed. - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined; - - const contentType = req.headers["content-type"]; - - try { - // #region forward - const upstream = await fetch(`${BLOCKFROST}${req.url ?? "/"}`, { - method: req.method, - headers: { - project_id: KEY, - ...(contentType ? { "content-type": contentType } : {}), - }, - body, - }); - // #endregion forward - - res.statusCode = upstream.status; - res.setHeader("content-type", upstream.headers.get("content-type") ?? "application/json"); - res.end(Buffer.from(await upstream.arrayBuffer())); - } catch (error) { - res.statusCode = 502; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ error: `could not reach Blockfrost: ${(error as Error).message}` })); - } -} -// #endregion handler - -// #region plugin -/// Mount the handler at `/api/blockfrost` on Vite's server, so `npm run dev` -/// starts the front and the back together. -/// -/// In production you run `handleBlockfrost` in a server of your own, it is a -/// plain Node request handler and knows nothing about Vite. -export function blockfrostProxy(): Plugin { - return { - name: "blockfrost-proxy", - configureServer(server) { - server.middlewares.use("/api/blockfrost", handleBlockfrost); - }, - configurePreviewServer(server) { - server.middlewares.use("/api/blockfrost", handleBlockfrost); - }, - }; -} -// #endregion plugin -// #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts index ed51ef3e35..5ed1cd7a0d 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts @@ -1,32 +1,49 @@ import { resolve } from "node:path"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { nodePolyfills } from "vite-plugin-node-polyfills"; -import { blockfrostProxy } from "./server/blockfrost.ts"; // Mesh uses Node built-ins (Buffer, crypto, stream) in the browser, so we polyfill them. -// `blockfrostProxy` is the backend: it serves /api/blockfrost, and it is the only -// thing here that reads the key. +// +// The proxy below is the only thing here that reads the Blockfrost key. It runs in +// Node, so the key never reaches the browser: the page calls /api/blockfrost/... on +// its own origin, and this rule forwards each call with the key attached. // // Two pages, both driving the same `src/lib`: // index.html -> src/main.tsx the styled vault, with the minting button // vault.html -> src/app.tsx the page the reader builds in lecture 9 -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), - blockfrostProxy(), - ], - server: { allowedHosts: true }, - build: { - target: "esnext", - rollupOptions: { - input: { - main: resolve(import.meta.dirname, "index.html"), - vault: resolve(import.meta.dirname, "vault.html"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, import.meta.dirname, ""); + const key = env.BLOCKFROST_API_KEY ?? ""; + + // #region proxy + const proxy = { + "/api/blockfrost": { + target: `https://cardano-${key.slice(0, 7)}.blockfrost.io/api/v0`, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/blockfrost/, ""), + headers: { project_id: key }, + }, + }; + // #endregion proxy + + return { + plugins: [ + react(), + tailwindcss(), + nodePolyfills({ globals: { Buffer: true, global: true, process: true } }), + ], + server: { allowedHosts: true, proxy }, + preview: { proxy }, + build: { + target: "esnext", + rollupOptions: { + input: { + main: resolve(import.meta.dirname, "index.html"), + vault: resolve(import.meta.dirname, "vault.html"), + }, }, }, - }, + }; }); From 7512c6d7b9cb7416727bf11728b94b48434a5c59 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 16:41:07 -0300 Subject: [PATCH 06/89] Quiet the offline tests and drop their unused import --- .../vault/off-chain/mesh/src/vault.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts index 81fcdbc6d1..4e01434203 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts @@ -6,6 +6,9 @@ import { test } from "node:test"; import { DEFAULT_PROTOCOL_PARAMETERS, + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, OfflineFetcher, deserializeAddress, serializeData, @@ -14,17 +17,19 @@ import type { Asset } from "@meshsdk/core"; import { OfflineEvaluator } from "@meshsdk/core-csl"; import { MeshWallet } from "@meshsdk/wallet"; -import { blueprint, vaultAddress } from "./lib/blueprint.ts"; +import { vaultAddress } from "./lib/blueprint.ts"; import { vaultDatum } from "./lib/datum.ts"; import { buildLockTx } from "./lib/lock.ts"; import { buildUnlockTx } from "./lib/unlock.ts"; // #endregion offline-imports -// The other tests in this file need more: the lock, mint and recover builders, -// and two helpers for applying a parameter by hand. The blueprint itself comes -// from `lib/blueprint.ts`, which is where its path is written down once. +// The other tests in this file need more: the mint and recover builders, and two +// helpers for applying a parameter by hand. The blueprint comes from +// `lib/blueprint.ts`, which is where its path is written down once. import { applyParamsToScript, serializePlutusScript } from "@meshsdk/core"; +import { blueprint } from "./lib/blueprint.ts"; + import { buildMintAndLockTx } from "./lib/mint.ts"; import { buildRecoverTx } from "./lib/recover.ts"; @@ -42,6 +47,13 @@ const OWNER = function newFetcher(): OfflineFetcher { const fetcher = new OfflineFetcher("preview"); fetcher.addProtocolParameters(DEFAULT_PROTOCOL_PARAMETERS); + // A pretend chain has no cost models. Without these the builder still works, + // it just logs a stack trace on its way to these very defaults. + fetcher.fetchCostModels = async () => [ + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + ]; return fetcher; } From 5af78d21a52f460b9c39b5e4abfd48e9f282afd7 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 16:41:08 -0300 Subject: [PATCH 07/89] Trim example comments the lecture already makes --- .../intermediate/vault/off-chain/mesh/src/app.tsx | 2 +- .../vault/off-chain/mesh/src/lib/blueprint.ts | 10 +++------- .../vault/off-chain/mesh/src/lib/datum.ts | 10 +++------- .../vault/off-chain/mesh/src/lib/fetch.ts | 15 +++------------ .../vault/off-chain/mesh/src/lib/lock.ts | 4 +--- .../vault/off-chain/mesh/src/lib/mint.ts | 5 ----- .../vault/off-chain/mesh/src/lib/recover.ts | 4 ++-- .../vault/off-chain/mesh/src/lib/unlock.ts | 14 +++----------- 8 files changed, 16 insertions(+), 48 deletions(-) diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx index d8281c3af0..5467c9aca5 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -75,7 +75,7 @@ function App() {

The vault lives at {vaultAddress(NETWORK_ID)}

- + diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts index 5194b6cb0e..54381b4164 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts @@ -5,18 +5,15 @@ // #region file import { applyParamsToScript, resolveScriptHash, serializePlutusScript } from "@meshsdk/core"; -// The blueprint your `aiken build` wrote, and the only file that names its -// path. The title is `..`, and your validator is in -// `vault.ak`. +// The blueprint your `aiken build` wrote, and the only file that names its path. // #replace ../../blueprints/vault.plutus.json -> ../../../on-chain/vault/plutus.json import blueprint from "../../blueprints/vault.plutus.json" with { type: "json" }; export { blueprint }; // #region recovery-const -// The recovery key your vault was compiled around: the parameter from the -// parameters lecture, filling the blank the compiler left. Any key hash works, -// but it fixes the address, so it has to be the same every time. +// The recovery key this vault is compiled around. It fixes the address, so it +// has to stay the same forever. const RECOVERY = "00000000000000000000000000000000000000000000000000000000"; // #endregion recovery-const @@ -36,7 +33,6 @@ export const vaultScriptCbor = applyParamsToScript(compiledCode(blueprint, "vaul // #endregion params /// The script's address: the hash of that script, written for one network. -/// Anything sent here can only be spent if the validator says yes. export function vaultAddress(networkId: number): string { return serializePlutusScript( { code: vaultScriptCbor, version: PLUTUS_VERSION }, diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts index 12589827b9..8bdbea0b36 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts @@ -2,20 +2,16 @@ import { mConStr0, mConStr1 } from "@meshsdk/core"; import type { Data } from "@meshsdk/core"; -/// The **datum**: who owns the locked UTxO, mirroring the on-chain -/// `VaultDatum { owner }`. +/// The datum: who owns the locked UTxO. export function vaultDatum(ownerPubKeyHash: string): Data { return mConStr0([ownerPubKeyHash]); } -/// The **redeemer**: the spender's action, mirroring `VaultAction`. `Unlock` is -/// the first constructor, so it is number 0, and it carries no fields. +/// The redeemer for `Unlock`. export const unlockRedeemer: Data = mConStr0([]); // #region recover -/// The second action, added in **parameters**. `Recover` is declared after -/// `Unlock` on-chain, so it is constructor **1**, the number is the whole -/// difference between the two. +/// The redeemer for `Recover`, added in parameters. export const recoverRedeemer: Data = mConStr1([]); // #endregion recover // #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts index 9a68831239..0d927add67 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts @@ -4,9 +4,8 @@ import type { IFetcher, UTxO } from "@meshsdk/core"; import { vaultAddress } from "./blueprint.ts"; -/// Read the owner back out of a locked UTxO's datum. Returns `undefined` if the -/// UTxO has no datum, or carries one this contract cannot read, anyone may send -/// funds to a script address, including by mistake. +/// The owner named in a locked UTxO's datum, or `undefined` if it has no datum +/// this contract can read. function ownerOf(utxo: UTxO): string | undefined { try { return String(deserializeDatum(utxo.output.plutusData ?? "").fields[0].bytes); @@ -15,15 +14,7 @@ function ownerOf(utxo: UTxO): string | undefined { } } -/// Read the UTxOs locked at the contract that name **you** as the owner. -/// -/// The filter is the part worth understanding. A script address is not yours: it -/// is the hash of the contract, so everyone who compiles this same contract gets -/// the same address, and everything they lock sits here alongside yours. Fetching -/// without filtering returns their UTxOs too, and the validator will refuse when -/// you try to spend one, because its datum names someone else. -/// -/// What makes a UTxO yours is the datum, not the address. +/// The UTxOs locked at the contract that name **you** as the owner. export async function fetchLocked( provider: IFetcher, networkId: number, diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts index 23ea1b7128..622c6c871f 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/lock.ts @@ -6,9 +6,7 @@ import { vaultAddress } from "./blueprint.ts"; import { vaultDatum } from "./datum.ts"; /// Build a transaction that **locks** `lovelace` at the contract's address, with -/// a datum naming the connected wallet as the **owner**, the only one who'll be -/// able to unlock it. This is an ordinary payment to a script address; the -/// contract doesn't run yet, it only runs later, when someone tries to unlock. +/// a datum naming the connected wallet as the owner. export async function buildLockTx( wallet: IWallet, provider: IFetcher, diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts index 957a0057bd..e871994480 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts @@ -10,11 +10,6 @@ export const VAULT_TOKEN_NAME = "VAULT"; /// Build a transaction that **mints one vault token and locks it**, together with /// `lovelace`, at the vault's address. -/// -/// One script does both jobs here, because its hash is both the address funds go -/// to and the policy id the token is created under. The `mint` handler runs to -/// approve the new token; the `spend` handler stays asleep, since locking is -/// still an ordinary payment. export async function buildMintAndLockTx( wallet: IWallet, provider: IFetcher, diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts index 2c5896fe0d..c4ca235bae 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts @@ -30,7 +30,7 @@ export async function buildRecoverTx( if (!collateral) { throw new Error( "no collateral: this wallet needs a UTxO holding at least 5 ADA and no tokens. " + - "Send it some test ADA and try again.", + "Send it some test ADA and try again.", ); } @@ -50,7 +50,7 @@ export async function buildRecoverTx( .txInScript(vaultScriptCbor) // The datum is already on the UTxO, so there is nothing to attach here. .txInInlineDatumPresent() -// #region recover-redeemer + // #region recover-redeemer // **The one line that differs from `unlock.ts`**: `Recover`, not `Unlock`. // This is what tells the validator which of its two branches to take. .txInRedeemerValue(recoverRedeemer) diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts index 242df2321b..44855367e7 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts @@ -6,18 +6,10 @@ import { vaultScriptCbor } from "./blueprint.ts"; import { unlockRedeemer } from "./datum.ts"; /// Build a transaction that **unlocks** `lockedUtxo`. This is where the contract -/// runs: the network hands the validator the datum (the owner), our redeemer, and -/// the transaction, and only lets the spend through if the owner signed it. +/// runs: the network hands the validator the datum, the redeemer and the +/// transaction, and only lets the spend through if the owner signed. /// -/// We declare the owner as a **required signer** so the wallet's signature is in -/// the transaction for the validator to check. Spending a script UTxO also needs -/// the **script**, the **redeemer**, and a **collateral** UTxO (a deposit the -/// network keeps only if the script unexpectedly fails). -/// -/// Passing an `evaluator` makes the builder **run the validator before it returns**, -/// so a spend the contract would refuse fails here rather than on the chain. Without -/// one the builder just guesses a cost budget and the refusal happens on-chain, which -/// is what costs you the collateral. +/// Pass an `evaluator` to run the validator here, before anything is sent. export async function buildUnlockTx( wallet: IWallet, provider: IFetcher, From 22635d567426785cbeb8046fe5a5393df28c2517 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 16:41:08 -0300 Subject: [PATCH 08/89] Add a Vercel entry point for the Blockfrost proxy --- .../off-chain/mesh/api/blockfrost/[...path].ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts new file mode 100644 index 0000000000..df4442b934 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts @@ -0,0 +1,18 @@ +// #region file +/// The same rule as `vite.config.ts`, for hosts that serve the built page as +/// static files. Vercel has no Vite process, so `/api/blockfrost/...` needs a +/// function. Set BLOCKFROST_API_KEY in the project's environment variables. +export const config = { runtime: "edge" }; + +export default async function handler(req: Request): Promise { + const key = process.env.BLOCKFROST_API_KEY ?? ""; + const { pathname, search } = new URL(req.url); + const path = pathname.replace(/^\/api\/blockfrost/, "") + search; + + return fetch(`https://cardano-${key.slice(0, 7)}.blockfrost.io/api/v0${path}`, { + method: req.method, + headers: { project_id: key, "content-type": "application/json" }, + body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(), + }); +} +// #endregion file From 76779ea12988c55333f7d92ae1f9706534d9283c Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 19 Aug 2026 16:41:08 -0300 Subject: [PATCH 09/89] Tighten lecture 9 and correct its claims --- .../intermediate/9-frontend-integration.md | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 66eb98d22f..b0d0b3ca7e 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -16,6 +16,7 @@ import FetchLib from "!!raw-loader!@site/examples/onboarding/lectures/intermedia import MintLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts"; import OfflineTests from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts"; import Minimal from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx"; +import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts"; # Off-chain and frontend integration @@ -129,6 +130,31 @@ The SDK project is just a `package.json`. `npm pkg set type=module` switches it Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed, so putting it at the root means every command in this track still runs from `cardano-vault/`. +One more file, so your editor understands the code you are about to write. Create `tsconfig.json` beside `package.json`: + +```json title="tsconfig.json" +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node", "vite/client"] + }, + "include": ["off-chain/src"] +} +``` + +Four of those lines are doing real work. `skipLibCheck` stops TypeScript checking Mesh's own dependencies and reporting errors from libraries you never imported. `types` brings in Node's globals, which the tests need, and Vite's, which is what makes `import.meta.env` a known thing. `resolveJsonModule` lets you import `plutus.json`. And `allowImportingTsExtensions` is what lets your imports say `./lib/lock.ts`, extension and all, the way Node runs them. + ### 2. From blueprint to address The first file you write, and the bridge the top of this lecture describes. Create `off-chain/src/lib/blueprint.ts`: @@ -168,15 +194,15 @@ These are the whole off-chain half: lock funds, find them again, unlock them, an An ordinary payment, with two additions. `deserializeAddress(...).pubKeyHash` pulls your key hash out of your address, which is what goes in the datum, and `.txOutInlineDatumValue(...)` attaches that datum to the output. No script, no collateral, no redeemer: the contract does not run when you lock. -Notice the wallet argument. It is typed as `IWallet`, the interface Mesh defines, and nothing here names a particular wallet. That is why the same file works with the seed-phrase wallet your tests use in step 5 and with the browser extension your page uses in step 7. - Then `off-chain/src/lib/unlock.ts`, which is where the contract does run: {extractRegion(UnlockLib, "file")} -Every extra line here is one item in that list. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. +Four of the extra lines are the four things a script spend adds. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. + +Three more lines say what is being spent. `.spendingPlutusScriptV3()` declares that this input is guarded by a script, `.txIn(...)` names the locked UTxO, and `.txInInlineDatumPresent()` says its datum is already on the chain, so there is nothing to attach. One argument is worth stopping on, because the next step is built on it. Passing an **evaluator** makes the builder run your **real compiled validator** before it returns anything. A spend the contract would refuse fails here, immediately, instead of on the chain where it would cost you the collateral. @@ -206,7 +232,7 @@ Create `off-chain/src/vault.test.ts`. The imports first: {extractRegion(OfflineTests, "offline-imports")} -Then a pretend chain and a wallet to go with it. `OfflineFetcher` is an in-memory chain you fill in yourself, and `MeshWallet` is a wallet built from a seed phrase rather than an extension: +Then a pretend chain and a wallet to go with it. `OfflineFetcher` is an in-memory chain you fill in yourself, and `MeshWallet` is a wallet built from a seed phrase rather than an extension. The cost-model lines are housekeeping: a pretend chain has none, and handing over the same defaults the builder would fall back to keeps the output clean: {extractRegion(OfflineTests, "offline-setup")} @@ -236,21 +262,21 @@ Run it: node --test off-chain/src/vault.test.ts ``` -Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly, which is why step 1 asked for 22.18 or newer. +Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly, which is why this lecture opened by asking for 22.18 or newer. -Expect some extra output above that result, including a warning that cost models fell back to defaults. That is the offline chain saying it has no real protocol parameters, the network's current fee and size settings, to hand out. Read the `pass` and `fail` counts at the bottom, not the messages above them. +Node prints one warning above that, about importing a WebAssembly module. It comes from Mesh loading the library that serialises transactions, and it is safe to ignore. **Now break the off-chain side, and watch which layer notices.** In `off-chain/src/lib/unlock.ts`, delete the `.requiredSignerHash(owner)` line and save. -Run `aiken check on-chain/vault` first, passing the project folder now that you are one level above it. All eight contract tests still pass, because the contract is still correct. Nothing is wrong with the rule. +Your contract is untouched, and its eight tests would still pass, because nothing is wrong with the rule. They never see your app, which is exactly the gap this level exists to close. -Then run the test file again. It fails, in the same few milliseconds, and the evaluator reports which script did the refusing: +Run the test file again. It fails, in the same few milliseconds, and the evaluator reports which script did the refusing: ``` "tag":"spend","errorMessage":"the validator crashed / exited prematurely" ``` -That `"tag":"spend"` says the refusal came from the spend validator, not from a transaction that failed to build. The gap between a correct contract and an app that builds the wrong transaction is invisible to a contract test, and this is exactly what catches it. Finding it took milliseconds and no test ADA. Finding it on the network would have meant locking real funds first and waiting for two confirmations. +That `"tag":"spend"` says the refusal came from the spend validator, not from a transaction that failed to build. It cost milliseconds and no test ADA. On the network you would have had to lock funds first and wait for that transaction to settle before you could even attempt the unlock that fails. Put the line back and run it once more to be sure. @@ -283,7 +309,7 @@ npm pkg set scripts.dev=vite npm pkg set scripts.build="vite build" ``` -`vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are for your editor rather than for any command here. `vite-plugin-node-polyfills` is the surprising one: Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have, so they have to be supplied. +`vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are what your `tsconfig.json` from step 1 has been describing; nothing here runs `tsc`. `vite-plugin-node-polyfills` is the surprising one: Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have, so they have to be supplied. Two small files Vite needs, and they are the only ones whose paths depend on where things sit in your workspace. `index.html` goes at the top of `cardano-vault/`, beside `package.json`, because Vite serves the folder you run it from: @@ -335,10 +361,20 @@ export default defineConfig(({ mode }) => { Four lines do the work. `target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both halves. -:::note This proxy runs with the dev server, not on the internet -`server.proxy` applies to `npm run dev` and `preview.proxy` to `npm run preview`, which is everything this lecture needs. A deployed site has no Vite, so hosting this page for real means giving your host the same rule: a redirect on Netlify or Vercel, a `location` block in nginx, or a small server of your own. What must stay true is the shape: the browser calls your origin, and something you control adds the key. +:::note Where this rule still applies once you deploy +It depends on what the host runs. On anything with a **Node process**, a container, a VPS, or a service that runs `npm run preview`, this same config serves the built page and proxies exactly as it does locally. On a **static host**, which is what Vercel and Netlify give a Vite app by default, there is no Node process: the page is served from a CDN and nothing answers `/api/blockfrost/…`. + +A redirect will not rescue the static case, because it passes the browser's headers along and cannot add your key. What has to stay true is the shape: the browser calls your own origin, and something you control attaches the key. ::: +**If you deploy it to Vercel**, that something is one file. Put it at `api/blockfrost/[...path].ts`, set `BLOCKFROST_API_KEY` in the project's environment variables, and change nothing else. Your page still calls `/api/blockfrost/…`, and Vercel routes it here instead of to Vite: + + + {extractRegion(VercelFn, "file")} + + +It is the same four decisions as the config: where the call really goes, strip the prefix, attach the key, hand the answer back. Returning `fetch(...)` straight out passes the status and body through untouched. The forwarding itself is portable, since it is plain `Request` in, `Response` out, but each host wants its own entry point: Netlify Edge Functions expect the file under `netlify/edge-functions/`, and Cloudflare Workers export `{ fetch }` and read secrets from an `env` argument rather than `process.env`. + **And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one. That is the whole swap, and it is why those builders were typed against the interface Mesh defines rather than against a particular wallet. So the last file you write is the page. Create `off-chain/src/app.tsx`: @@ -405,7 +441,7 @@ Stuck? The finished code is in the playground — see the **[introduction](/docs You started with an empty folder. You now have a contract you wrote and tested, with two purposes under one hash, and an app that locks, mints and unlocks real test ADA through it. -Look back at what each half cost. The contract took six lectures, because every one of them changed what the rule was. The app took one, because there was only ever one shape to it: derive the address, attach the datum, spend the UTxO, hand it to a wallet. That difference is not an accident of this example. It is the normal shape of Cardano work, and it is why the rest of this track goes straight back to contracts. +Notice the balance. Six lectures went into the contract, and every one of them added something to it. One went into the app, because its shape never changed: derive the address, build a transaction, hand it to a wallet. That is the usual balance of Cardano work, and it is why the rest of this track goes back to contracts. Each of the remaining lectures is the same shape with a different rule in the middle. The contracts arrive finished, and each lecture has you break one and write the missing rule back: From c939accd5f88fb1a7a3f94dc5d40dd5f9e9d5681 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Tue, 25 Aug 2026 11:16:54 -0300 Subject: [PATCH 10/89] Apply the review feedback on the Intermediate lectures --- .../intermediate/1-on-chain-vs-off-chain.md | 44 ++++++++---- .../lectures/intermediate/2-tools.md | 42 +++++------ .../intermediate/3-what-is-a-validator.md | 30 ++++---- .../intermediate/4-datum-and-redeemer.md | 16 ++--- .../intermediate/5-transaction-context.md | 8 +-- .../lectures/intermediate/6-testing.md | 14 ++-- .../lectures/intermediate/7-parameters.md | 16 ++--- .../intermediate/8-validator-purposes.md | 14 ++-- .../intermediate/9-frontend-integration.md | 32 ++++----- .../lectures/intermediate/introduction.md | 71 ++++++++----------- 10 files changed, 146 insertions(+), 141 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md index 0a8336ce9a..5240d30bd4 100644 --- a/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md +++ b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md @@ -1,17 +1,31 @@ --- title: "On-chain vs off-chain" sidebar_label: "On-chain vs off-chain" -description: "Every Cardano app has two halves: off-chain code that builds transactions, and an on-chain contract that enforces the rules." +description: "What a dApp is made of, and the line between off-chain code that builds transactions and an on-chain contract that enforces the rules." --- # On-chain vs off-chain Welcome to the Intermediate track. In Beginner you moved value around. Now you will make the chain **enforce rules** about how that value moves. That is a **smart contract**. -One idea has to be clear before any code, because the rest of the track is built on it. The apps you built [in Beginner](/docs/developers/onboarding/lectures/beginner/introduction) had only one half: your code. Add a smart contract and there is a second half. The two do completely different jobs. +## What a dApp is -- **Off-chain** is the code that runs **on your computer or server** (your app, plus an off-chain SDK). It reads the chain, **builds transactions**, and asks the wallet to sign them. This is the same work you did for the [send](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) and [mint](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) transactions in Beginner. It **prepares**. -- **On-chain** is the **smart contract that lives on the blockchain**. It is a rule that runs when someone tries to spend locked funds, and it either **approves or rejects** the transaction. It **enforces**. +An app built on a blockchain is called a **dApp**, short for decentralized application. A few separate pieces make one up: + +- **A frontend**: the page people see and click. +- **Off-chain code**: normally part of that same page. It reads the chain and builds the transactions. +- **A [provider](/docs/developers/onboarding/lectures/beginner/providers-and-explorers)**: how the dApp reads the chain, and how it gets a finished transaction out to the network. +- **A wallet**: holds the keys and signs. On the web it is usually a browser extension, like the Lace you installed in Beginner. +- **A smart contract**: the rule the network enforces. + +You built the first four in Beginner, and **[a transaction, step by step](/docs/developers/onboarding/lectures/beginner/providers-and-explorers#a-transaction-step-by-step)** shows them working together. The smart contract is what this track adds. + +One idea has to be clear before any code, because the rest of the track is built on it. Two of those pieces do completely different jobs: + +- **Off-chain** is the code that runs **in your browser or on a server** (your app, plus an off-chain SDK). It reads the chain, **builds transactions**, and asks the wallet to sign them. This is the same work you did for the [send](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) and [mint](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) transactions in Beginner. It **prepares**. +- **On-chain** is the **smart contract (logic) and data that lives on the blockchain**. A Cardano smart contract is code that runs on the blockchain and checks whether the transaction is allowed. It either **approves or rejects** the transaction. It **enforces**. + +The apps you built [in Beginner](/docs/developers/onboarding/lectures/beginner/introduction) had only off-chain code. Think of applying for a permit to build something. Your app is the person applying: it decides what it wants to build, fills in every field, and hands the form in. The contract is the officer who reads the form and either approves it or rejects it. The person can ask for anything, and the officer decides what is allowed. Notice what the officer never does. They do not decide what to build, and they do not build it themselves. They only decide yes or no. @@ -36,7 +50,7 @@ Read it from left to right. Everything in the left box is your side of the line. Split any Cardano app along that line and it becomes much easier to understand: -| Off-chain (your code, your machine) | On-chain (the contract, the network) | +| Off-chain (your server/browser) | On-chain (the network) | |---|---| | Read the chain: which UTxOs exist, what's locked where | - | | Decide what _should_ happen | Check whether it's **allowed** | @@ -55,13 +69,15 @@ That single requirement explains most of what feels strange at first: - **A contract cannot call an API**, read a price feed, or fetch anything. Two nodes asking the same server could get two different answers. - **A contract cannot read a clock.** This is why time became a **slot window** that you declare in advance, back in [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano). -- **A contract keeps no variables of its own between runs.** This does not mean nothing is saved. On Cardano, state lives **on the UTxOs** rather than inside the contract, and everything the validator needs to know must reach it through the transaction. The next two lectures show how. +- **A contract keeps no variables of its own between runs.** This does not mean nothing is saved. On Cardano, state lives **on the UTxOs** rather than inside the contract, and everything the validator needs to know must reach it through the transaction context. The next two lectures show how. - **A contract cannot start anything.** Nothing on Cardano happens because a contract decided to act. Someone has to build a transaction first. -You get something valuable in return. Because nothing is measured at the moment of checking, your app can have the contract run **before sending the transaction** and already know what it will answer. A spend the contract would refuse is caught then, and never has to be sent. +You get something valuable in return: your transactions are **deterministic**. A validator only ever looks at information that is local to the transaction and cannot change once it is written, so running it twice gives the same answer twice, on your machine and on every node. That is why your app can run the contract before sending anything, and know whether the contract approves the transaction and what running it will cost. That is a promise about the **contract's answer**, not about the transaction getting in. Somebody else may spend the same UTxO first, and then the ledger refuses yours before the contract is even consulted. So the guarantee is: **if** your transaction is accepted, it does exactly what you predicted. Not that it is certain to be accepted. +There is a practical reason for the split as well. Everything on-chain is stored by every node and re-checked forever, so moving the transaction building there too would grow the chain faster than most people could afford to keep up with, and a chain only a few can verify is not decentralized. + ## Two things that surprise newcomers **The contract does not run on your computer.** You write it, compile it, and read it in your editor, so it is easy to think of it as part of your app. It is not. Your app carries the compiled contract **inside the transaction**, and the **network** runs it when that transaction is checked. The answer is the same for everyone, forever. @@ -70,7 +86,7 @@ That is a promise about the **contract's answer**, not about the transaction get ## Try it -**Make the folder you will work in for the rest of the track.** It holds the two halves of this lecture, one each, and you fill them yourself: +**Make the folder you will work in for the rest of the track:** ```bash mkdir cardano-vault @@ -95,17 +111,17 @@ Both are empty. The next lecture puts a contract project in `on-chain/` and leav Keep the name or pick your own, and read `cardano-vault/` as "wherever you put it". -Which half a file belongs in is always worth knowing. Code in `off-chain/` can be wrong, or replaced. The network does not care, because it checks every transaction against what is in `on-chain/`. Code in `on-chain/` is the part the network enforces. +Whether a file is on-chain or off-chain is always worth knowing. Code in `off-chain/` can be wrong, or replaced. The network does not care, because it checks every transaction against what is in `on-chain/`. Code in `on-chain/` is the part the network enforces. For the vault you are about to build, the split runs like this. Off-chain builds a transaction that sends ADA to the contract's address, which locks it. Later, off-chain builds a second transaction that tries to spend it back, so on-chain, the validator runs and answers yes or no, and only a "yes" allows the spend. Every contract you write follows this shape. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — the on-chain/off-chain split in full. -- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — the lock-then-spend flow end to end. -- [Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers) — the account-model habits that don't carry over: no `msg.sender`, no contract storage, no execution order. -- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — the ledger model that makes this split possible. +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview): the on-chain/off-chain split in full. +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend): the lock-then-spend flow end to end. +- [Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers): the account-model habits that don't carry over: no `msg.sender`, no contract storage, no execution order. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo): the ledger model that makes this split possible. Next: **[Set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. diff --git a/docs/developers/onboarding/lectures/intermediate/2-tools.md b/docs/developers/onboarding/lectures/intermediate/2-tools.md index 79e28e5c59..f2322c604d 100644 --- a/docs/developers/onboarding/lectures/intermediate/2-tools.md +++ b/docs/developers/onboarding/lectures/intermediate/2-tools.md @@ -1,7 +1,7 @@ --- title: "Set up your tools" sidebar_label: "Set up your tools" -description: "The language and compiler for the on-chain half, and the contract project everything else in this track fills." +description: "The compiler for the on-chain half, and the contract project everything else in this track fills." --- import Tabs from "@theme/Tabs"; @@ -9,17 +9,15 @@ import TabItem from "@theme/TabItem"; # Set up your tools -Two halves, two sets of tools. The on-chain half needs a **language and a compiler**, because a rule has to become a program the network can run. The off-chain half needs a **library, a provider and a wallet**, because your app has to build transactions, read the chain and get things signed. +Two halves, two sets of tools. The on-chain half needs a **compiler and supporting tooling**, because a contract has to become a program the network can run. The off-chain half needs a **library, a provider, and a way to interact with a wallet**, because your app has to read the chain, build transactions, get them signed, and submit them. **You only need the first set now.** The next six lectures are the contract and nothing else: you write it, compile it, test it, and finish it. The app comes afterwards, once the contract is done, so the focus stays on the contract's own concepts. The off-chain half then arrives all at once in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. -So this lecture sets up one project, and leaves it empty. Everything up to lecture 8 fills it. - ## The on-chain toolchain You do not write the code the network runs. You write it in a high-level language and **compile** it. Several languages do this for Cardano: -- **[Aiken](https://aiken-lang.org/)** is made for Cardano contracts. It is a small language with a fast compiler and built-in tests, and it is the easiest place to start. This track uses it. +- **[Aiken](https://aiken-lang.org/)** is a language made from scratch to write Cardano contracts. It is a small language with a fast compiler and built-in tests, and it is the easiest place to start. - **[Scalus](https://scalus.org/)** lets teams who already use Scala write contracts in the language they know. - Others exist for Haskell, Python and TypeScript teams. The [handbook compares them](/docs/developers/curriculum/smart-contracts/choose-a-language), and **[Builder Tools](/tools)** lists them all. @@ -27,13 +25,13 @@ Different languages, **same output**. They all compile to the same low-level pro ## The off-chain toolchain, so you know what is coming -Nothing to install here. This is the shape of the other half, so that the choices you make now make sense. Three pieces, each with one job. +Nothing to install here. This is the shape of the off-chain half, so that the choices you make now make sense. Three pieces, each with one job. -**The SDK** builds Cardano transactions for you. Without one, every transaction would cost you a lot of time and a lot of code. The examples in this track are written with **[Mesh](https://github.com/MeshJS/mesh)**, and every code block sits in a tab, with **[Evolution](https://github.com/IntersectMBO/evolution-sdk)** beside it. **[Tx3](https://github.com/tx3-lang/tx3)** works differently, you describe the transaction, and it generates the code. +**The SDK** builds Cardano transactions for you. Without one, every transaction would cost you a lot of time and a lot of code. There are SDKs for JavaScript, Python, Haskell, Java, Go and more, and **[Builder Tools](/tools)** lists them all. -Those three are a choice, not a rule. There are SDKs for Python, Haskell, Java, Go and more, and **[Builder Tools](/tools)** lists them by language. Nothing in these lectures depends on the one you pick: the contract is the same, the transaction is the same, only the function names change. +Nothing in these lectures depends on the one you pick: the contract is the same, the transaction is the same, only the function names change. Every code block that needs an SDK sits in a tab, so you can read the track in whichever one you use, and more will be added over time. -**The provider** reads the chain for you and submits your transactions, because your app cannot reach the network by itself. Beginner used one already. This track leans on it harder, for two reasons: +**The provider** reads the chain for you and submits your transactions because your app cannot reach the network on its own unless you run your own Cardano node. Beginner used one already. This track leans on it harder, for two reasons: - **You read UTxOs that are not yours.** Locked funds sit at a contract's address. Your wallet knows nothing about them, so the provider is the only way to find them. - **A script transaction has to declare its cost.** Running a validator uses CPU and memory, and the transaction carries the budget it expects to use, written next to the redeemer. You also pay for that budget in the fee. So something has to run the contract first, against your unsigned transaction, to find the real number. Your SDK can do that on your machine, or hand the job to a provider that offers it. Either way the answer arrives before you send anything, which is why a contract that says no usually fails in your app rather than on the chain. @@ -42,8 +40,6 @@ You made a free **[Blockfrost](https://blockfrost.io/)** Preview key during setu **The wallet** holds the keys and signs. Your app never sees a private key: it hands the finished transaction to the wallet, the wallet asks the user, and the user approves. Here that is **[Lace](https://www.lace.io/)** on Preview. -So: **SDK builds and prices, provider reads and submits, wallet signs.** The network decides. - Keep your Blockfrost key and your Lace wallet where they are. Neither is touched again until **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, which sets all three of these up in one go. ## Try it @@ -61,9 +57,9 @@ aiken new my-name/vault cd vault ``` -`aiken new` creates the folder in whichever folder you run it from, and fills it with a working project: `aiken.toml` for the settings and dependencies, and `validators/` for your contracts. The name is `{organisation}/{repository}`, the same form as the dependencies you will add later, so `my-name/` is a label you can set to anything and `vault` is what the project is called. +`aiken new` creates the folder in whichever folder you run it from, and fills it with a working project: `aiken.toml` for the settings and dependencies, and `validators/` for your contracts. "Smart contract" is the general word, and the thing you actually write is a **validator**, which is why that folder has the name it does. **[The next lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** writes your first one. The name is `{organisation}/{repository}`, the same form as the dependencies you will add later, so `my-name/` is a label you can set to anything and `vault` is what the project is called. -That last `cd` matters more than it looks. Aiken's commands act on the project you are standing in, `aiken add` among them, so the next six lectures all run from inside `on-chain/vault/`. You leave it once, in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and that is the last folder change in the track. +Aiken's commands run in the project you're in, so the next six lectures all run from inside `on-chain/vault/`. `aiken new` leaves a sample validator behind. You do not need it, and it would end up in your compiled output, so delete it: @@ -71,8 +67,6 @@ That last `cd` matters more than it looks. Aiken's commands act on the project y rm validators/placeholder.ak ``` -Libraries come later, when a lecture needs one. - Check that the project works: ```bash @@ -91,7 +85,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -Your workspace now has something in one half of it: +Your workspace now has something in the on-chain folder: ``` cardano-vault/ @@ -102,17 +96,17 @@ cardano-vault/ └── off-chain/ <- still empty, filled in lecture 9 ``` -That empty `off-chain/` is not an oversight. It is the shape of the work: the contract is what you are learning, and it changes with every idea in the next six lectures. The app(off-chain) that drives it barely changes at all, which is why it is worth writing once, at the end, against a contract that has stopped moving. +You are ready to write your first validator. Keep going in the next lecture. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Choose a Smart Contract Language](/docs/developers/curriculum/smart-contracts/choose-a-language) — Aiken, Scalus and the rest, and why they all compile to the same core. -- [Choose your tools](/docs/developers/curriculum/start-building/choose-your-tools) — how to pick an off-chain library. -- [Builder Tools](/tools) — every SDK, library and API on the portal, filtered by language. -- [Use a provider](/docs/developers/curriculum/production/use-a-provider) — hosted, self-hosted, and local options. -- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — reading addresses, UTxOs and datums. -- [Testing](/docs/developers/curriculum/smart-contracts/testing) — unit tests, property tests, and how far you can get before touching a chain. +- [Choose a Smart Contract Language](/docs/developers/curriculum/smart-contracts/choose-a-language): Aiken, Scalus and the rest, and why they all compile to the same core. +- [Choose your tools](/docs/developers/curriculum/start-building/choose-your-tools): how to pick an off-chain library. +- [Builder Tools](/tools): every SDK, library and API on the portal. +- [Use a provider](/docs/developers/curriculum/production/use-a-provider): hosted, self-hosted, and local options. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain): reading addresses, UTxOs and datums. +- [Testing](/docs/developers/curriculum/smart-contracts/testing): unit tests, property tests, and how far you can get before touching a chain. Next: **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**. diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 4795f7c361..c6cb4723d1 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -1,7 +1,7 @@ --- title: "What a validator is" sidebar_label: "What a validator is" -description: "A smart contract on Cardano is a validator: a small yes/no function the network runs to approve or reject spending a locked UTxO." +description: "A smart contract on Cardano is a validator: a small yes/no function the network runs to approve or reject a transaction." --- import Tabs from "@theme/Tabs"; @@ -9,13 +9,19 @@ import TabItem from "@theme/TabItem"; # What a validator is -A smart contract on Cardano is a **validator**: a small function the network runs when someone tries to spend a **locked** UTxO. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the spend happens. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. +A smart contract on Cardano is a **validator**: a small function the network runs when a transaction tries to do something that validator guards. Spending a **locked** UTxO is the most common case, and the one this lecture uses. Minting is another, and your vault gains that purpose in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the action is allowed. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. -Here is the part that surprises people: **a validator never moves funds.** It is not a program that holds money and pays it out. It is a **guard at a door**. A guard does not carry anything in or out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. It works the same way here. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. +Here is the part that surprises people: **a validator never moves funds.** Think of it as a **guard at a door** rather than a program that holds money and pays it out. A guard does not carry anything in or out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. It works the same way here. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. So a validator is defined by what it **refuses**. A guard who lets everyone through is not guarding anything. Writing a contract means choosing the cases where you say no. -One more point before we continue. "A smart contract" does not always mean *one* validator. A real application often uses a **set of validators that work together**. Each one protects its own thing, and each one judges the same transaction. Our examples use a single validator for now. **Multi validators** shows one script guarding two different actions at once, and **reference inputs** shows two separate contracts working together. +## One contract, several validators + +"A smart contract" does not always mean *one* validator. A real application often uses several. Each one protects its own thing, and each one judges the same transaction on its own, without ever calling the others. + +What ties them together is a single rule: **every validator the transaction triggers has to say yes.** One no anywhere, and the whole transaction is rejected. That is how contracts cooperate on Cardano, by each making its own demand of the same transaction. + +Our examples use a single validator for now. **Multi validators** shows one script guarding two different actions at once, and **reference inputs** shows two separate contracts working together. ## Where the locked funds live @@ -25,11 +31,11 @@ You have already met the other kind. When Bob locked 5 ADA behind a native scrip ```mermaid flowchart TB - subgraph K["Key address — controlled by a person"] + subgraph K["Key address: controlled by a person"] KA["10 ADA sitting here"] --> KR["to spend it:
sign with the matching private key"] end - subgraph S["Script address — controlled by a rule"] + subgraph S["Script address: controlled by a rule"] SA["5 ADA sitting here"] --> SR["to spend it:
build a transaction the validator approves"] end @@ -140,7 +146,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## What compiling produced @@ -153,13 +159,13 @@ Open it. Four things are inside: - **`compiledCode`:** the actual program, as a hex string. This is the **only** part the network ever runs. It is a low-level language called UPLC, and every contract language compiles down to it. - **`definitions`:** the shapes of your datum and redeemer types, which is [the next lecture](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer). Right now they are just `Data`, because your validator accepts anything. -Notice what is **not** in there: the address. It is not part of the compiled output. It is built from the hash, and it depends on which network you are on. +Notice what is **not** in there: the address. It is built from the hash, and it depends on which network you are on. ## Go deeper -- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator) — the gatekeeper model, with real validator code. -- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — "validators, not actors." -- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses) — key addresses, script addresses, and how each one is built. -- [Smart contract security](/docs/developers/curriculum/smart-contracts/security#locked-value) — the "locked value" section, on what actually happens when a validator can never say yes. +- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator): the gatekeeper model, with real validator code. +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview): "validators, not actors." +- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses): key addresses, script addresses, and how each one is built. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security#locked-value): the "locked value" section, on what actually happens when a validator can never say yes. Next: **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 57a74f0a5f..849da1f0fc 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -28,7 +28,7 @@ sequenceDiagram participant Net as Network participant Vault as The script address
(no wallet, no keys, no owner) - Note over You,Vault: Transaction 1 — locking + Note over You,Vault: Transaction 1, locking You->>Net: sign + submit a payment to the script address,
with the datum attached Net->>Vault: an ordinary payment, accepted. The 5 ADA sits here Note over Net: the validator does not run:
nothing is being unlocked yet @@ -36,7 +36,7 @@ sequenceDiagram Note over You,Vault: later. Anyone may try to spend that UTxO - Note over You,Vault: Transaction 2 — unlocking + Note over You,Vault: Transaction 2, unlocking You->>Net: sign + submit a spend of that UTxO,
with the redeemer attached Net->>Net: run the validator on three things:
the datum (read off the UTxO), the redeemer (from this transaction),
and the context (this transaction itself) Net-->>You: True, or False @@ -55,7 +55,7 @@ Our example contract is a **vault**. It locks some funds so that only their owne - + {extractRegion(VaultSimple, "types")} @@ -196,13 +196,13 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o **And the redeemer?** You cannot watch it decide anything yet, and that is worth saying plainly rather than inventing a contract to hide it. `VaultAction` offers one choice, so every spender sends the identical thing and it changes no outcome. A redeemer only starts doing work once there is more than one action to pick from, which is what happens in **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context) — the full model, with a vesting example. -- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — how a datum rides along on an output. -- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — datum and redeemer inside a complete lock/spend flow. -- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — reading datums back out from your app. +- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context): the full model, with a vesting example. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo): how a datum rides along on an output. +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend): datum and redeemer inside a complete lock/spend flow. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain): reading datums back out from your app. Next: **[The transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 6172a8280f..564ab6ce05 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -157,12 +157,12 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context) — the full field list, with the checks contracts most often write. -- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo) — why a transaction is a complete, self-contained thing to check. -- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — most real bugs are a context check that was missing. +- [Datum, Redeemer, and ScriptContext](/docs/developers/curriculum/smart-contracts/datum-redeemer-context): the full field list, with the checks contracts most often write. +- [The Extended UTXO Model](/docs/developers/curriculum/fundamentals/core-concepts/eutxo): why a transaction is a complete, self-contained thing to check. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security): most real bugs are a context check that was missing. Next: **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)**. diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 02beb67060..d4cc75d914 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -12,7 +12,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme # Testing -A contract has no update button. Once funds sit behind it, a mistake is not a bug you can fix. It is value that is lost or given away. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** put it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. +A contract has no update button. Once funds sit behind it, a mistake means value lost or given away, and no patch can take it back. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** put it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. So the question is not whether to test. It is how to be sure **before** anything real is at risk. @@ -54,7 +54,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o ## Tracing: reading a refusal -This is not a separate level. It is how you read the answer at any of them. +Tracing works at any of those levels: it is how you read the answer the contract gave. A validator only ever answers yes or no. That is all the chain needs, but it is thin when a test goes red: you learn *that* the contract refused and nothing about *which* check refused it. Your vault has one check, so there is only one suspect. A contract with a dozen leaves twelve, and by the end of this track yours will have several. @@ -182,13 +182,13 @@ You now have something the next two lectures need. **[Parameters](/docs/develope Every contract in the rest of the track arrives with tests already written, in the same style. Each of those lectures names its own project path, so the command keeps the shape you have been using here. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Testing](/docs/developers/curriculum/smart-contracts/testing) — the test runner, mock transactions, and property testing in depth. -- [Offline testing](/docs/developers/curriculum/start-building/offline-testing) — mocking the chain and evaluating budgets without a node. -- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — the failure modes worth writing tests against. -- [Audits](/docs/developers/curriculum/smart-contracts/security#audits) — when to bring in outside review, and how to prepare for it. +- [Testing](/docs/developers/curriculum/smart-contracts/testing): the test runner, mock transactions, and property testing in depth. +- [Offline testing](/docs/developers/curriculum/start-building/offline-testing): mocking the chain and evaluating budgets without a node. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security): the failure modes worth writing tests against. +- [Audits](/docs/developers/curriculum/smart-contracts/security#audits): when to bring in outside review, and how to prepare for it. Next: **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 9c52fbe8d7..fc1afaee74 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -24,7 +24,7 @@ So a parameter is one more way a fact reaches a contract, sitting on a different | **datum** | lock time | the locked UTxO | lock a new UTxO | | **redeemer** | spend time | the spending transaction | just send a different one | -The context is missing from that table on purpose. It is not a value you choose and hand over: it is the transaction itself, and it is settled by whoever builds the spend. +The context is missing from that table on purpose. It is the transaction itself, settled by whoever builds the spend, rather than a value you choose and pass. Why would you want one? Think about the vault so far. It releases funds to one owner, proven by one signature. Lose that key and the funds are gone for good, with nobody to ask for help. So we add a **backup key**: a second key, chosen when the vault is made, that can also take the funds out. The owner uses their key for normal spending. The backup is kept somewhere safe and is not touched until it is needed. @@ -35,11 +35,11 @@ Here are all three ways in, in one small contract: It is your vault with the backup key added, and the **[Try it](#try-it)** below makes exactly this change to the `vault.ak` you have been building: - + {extractRegion(VaultAiken, "types")} - + {extractRegion(VaultAiken, "vault", "mint-handler")} @@ -78,7 +78,7 @@ flowchart LR **addr_test1wpc707…**`"] ``` -Those two addresses come from one file and two recovery keys, with nothing in common between them. This is not a side effect to work around: it is the reason to use a parameter at all. +Those two addresses come from one file and two recovery keys, with nothing in common between them. That difference is the reason to use a parameter at all. Anyone can read the recovery key straight out of the contract. That is fine, because it is a public key **hash**, the same kind of value the datum holds. It names *who* may recover, and naming somebody is not the same as being them: taking the funds still needs a **signature** from that key, and only its owner can produce one. @@ -179,12 +179,12 @@ Open `plutus.json` and look at the entry for `vault.vault.spend`. It has grown a The blank itself is still empty. Filling it in is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does, and until something does, this contract has no address at all. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Parameterized scripts](/docs/developers/curriculum/smart-contracts/lock-and-spend#parameterized-scripts) — applying parameters from an SDK, with typed and untyped versions. -- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses) — how a script hash becomes an address in the first place. -- [Smart contract security](/docs/developers/curriculum/smart-contracts/security) — what belongs in a parameter, and what must never go anywhere public. +- [Parameterized scripts](/docs/developers/curriculum/smart-contracts/lock-and-spend#parameterized-scripts): applying parameters from an SDK, with typed and untyped versions. +- [Addresses](/docs/developers/curriculum/fundamentals/core-concepts/addresses): how a script hash becomes an address in the first place. +- [Smart contract security](/docs/developers/curriculum/smart-contracts/security): what belongs in a parameter, and what must never go anywhere public. Next: **[Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 8ac509380d..ad37f4a85a 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -1,7 +1,7 @@ --- title: "Validator purposes" sidebar_label: "Validator purposes" -description: "One validator can guard different things — spending a UTxO, minting tokens, withdrawing rewards — depending on its purpose." +description: "One validator can guard different things depending on its purpose: spending a UTxO, minting tokens, withdrawing rewards." --- import Tabs from "@theme/Tabs"; @@ -46,7 +46,7 @@ flowchart TD guards reward withdrawals`"] ``` -That is not three scripts. It is one compiled script doing three jobs. The hash **is** the script's identity, and where you put that hash decides which question the network asks it. Put it in an address and it guards funds. Put it on a token as the policy ID and it guards who may create that token. Register it as a stake credential and it guards rewards. +That is one compiled script doing three jobs. The hash **is** the script's identity, and where you put that hash decides which question the network asks it. Put it in an address and it guards funds. Put it on a token as the policy ID and it guards who may create that token. Register it as a stake credential and it guards rewards. The result is more useful than it first sounds. Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. @@ -211,13 +211,13 @@ That is the vault: a spend rule with two doors, a mint rule guarding its own tok Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the other half of the track, and it is the whole off-chain side in one go: the address, the transactions that lock, unlock, recover and mint, the tests that drive them, and a page in a browser with buttons on it. It can be written straight through now, without stopping, precisely because the contract behind it has stopped moving. -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator) — "one validator, many purposes, one hash," with real handlers. -- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview) — the full purpose table. -- [Minting policies](/docs/developers/curriculum/native-tokens/minting-policies) — the mint purpose in depth, native and script policies side by side. -- [Staking](/docs/developers/curriculum/staking-governance/staking) — where stake credentials and the withdraw purpose fit in. +- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator): "one validator, many purposes, one hash," with real handlers. +- [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview): the full purpose table. +- [Minting policies](/docs/developers/curriculum/native-tokens/minting-policies): the mint purpose in depth, native and script policies side by side. +- [Staking](/docs/developers/curriculum/staking-governance/staking): where stake credentials and the withdraw purpose fit in. Next: **[Off-chain and frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index b0d0b3ca7e..24b3f7ac63 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -1,7 +1,7 @@ --- title: "Off-chain and frontend integration" sidebar_label: "Frontend integration" -description: "The other half of a contract: deriving its address, building the transactions that lock and unlock, proving them offline, and wiring the whole thing to a wallet in the browser." +description: "The off-chain half of a contract: deriving its address, building the transactions that lock and unlock, proving them offline, and wiring the whole thing to a wallet in the browser." --- import Tabs from "@theme/Tabs"; @@ -75,7 +75,7 @@ Vite, the build tool that serves and bundles your page, draws that line for you: So the key has to live somewhere the browser never reaches: your **page** builds transactions and holds no secrets, and a small **proxy**, running on a machine you control, holds the key and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. -## The whole flow, both halves together +## The whole flow, end to end ```mermaid sequenceDiagram @@ -165,7 +165,7 @@ The first file you write, and the bridge the top of this lecture describes. Crea Four things in it are worth reading slowly: -- **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves touch, and it is a file, not a network call. +- **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves of your workspace touch, and it is a file, not a network call. - **The title** `vault.vault.spend` is `..`, so it names your `vault.ak`, its `vault` validator, and its spend handler. - **`applyParamsToScript`** fills the blank from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. These are the two lines that lecture promised you. - **`RECOVERY`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out, but whatever you choose has to stay the same forever. @@ -359,7 +359,7 @@ export default defineConfig(({ mode }) => { }); ``` -Four lines do the work. `target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both halves. +Four lines do the work. `target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both. :::note Where this rule still applies once you deploy It depends on what the host runs. On anything with a **Node process**, a container, a VPS, or a service that runs `npm run preview`, this same config serves the built page and proxies exactly as it does locally. On a **static host**, which is what Vercel and Netlify give a Vite app by default, there is no Node process: the page is served from a CDN and nothing answers `/api/blockfrost/…`. @@ -435,7 +435,7 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming -Stuck? The finished code is in the playground — see the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. +Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## That is the vault, finished @@ -445,19 +445,19 @@ Notice the balance. Six lectures went into the contract, and every one of them a Each of the remaining lectures is the same shape with a different rule in the middle. The contracts arrive finished, and each lecture has you break one and write the missing rule back: -- **Handling time** — funds that cannot move before a date. -- **Multi validators** — a token that acts as a key, where burning it is what opens the lock. -- **Modifying state** — data that is updated instead of released. -- **Reference inputs & scripts** — one contract reading another's data. +- **Handling time**: funds that cannot move before a date. +- **Multi validators**: a token that acts as a key, where burning it is what opens the lock. +- **Modifying state**: data that is updated instead of released. +- **Reference inputs & scripts**: one contract reading another's data. ## Go deeper -- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend) — the same two transactions, using more of what the SDK offers. -- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain) — providers, and reading datums back out. -- [Use a provider](/docs/developers/curriculum/production/use-a-provider) — keys, quotas and what to do when one goes down. -- [Offline testing](/docs/developers/curriculum/start-building/offline-testing) — mocking the chain and evaluating budgets without a node. -- [Connect a wallet](/docs/developers/curriculum/dapps/connect-a-wallet) — CIP-30 in full, and the backend-builds pattern this lecture starts. -- [Going to production](/docs/developers/curriculum/production/going-to-production) — the rest of the checklist this is one line of. -- [Optimization](/docs/developers/curriculum/smart-contracts/advanced/optimization) — keeping execution units, and therefore fees, down. +- [Lock and Spend](/docs/developers/curriculum/smart-contracts/lock-and-spend): the same two transactions, using more of what the SDK offers. +- [Query the chain](/docs/developers/curriculum/start-building/query-the-chain): providers, and reading datums back out. +- [Use a provider](/docs/developers/curriculum/production/use-a-provider): keys, quotas and what to do when one goes down. +- [Offline testing](/docs/developers/curriculum/start-building/offline-testing): mocking the chain and evaluating budgets without a node. +- [Connect a wallet](/docs/developers/curriculum/dapps/connect-a-wallet): CIP-30 in full, and the backend-builds pattern this lecture starts. +- [Going to production](/docs/developers/curriculum/production/going-to-production): the rest of the checklist this is one line of. +- [Optimization](/docs/developers/curriculum/smart-contracts/advanced/optimization): keeping execution units, and therefore fees, down. Next: **Handling time: vesting**. diff --git a/docs/developers/onboarding/lectures/intermediate/introduction.md b/docs/developers/onboarding/lectures/intermediate/introduction.md index 681639d18c..e170bfbc5d 100644 --- a/docs/developers/onboarding/lectures/intermediate/introduction.md +++ b/docs/developers/onboarding/lectures/intermediate/introduction.md @@ -1,7 +1,7 @@ --- title: "Intermediate: smart contracts" sidebar_label: "Introduction" -description: "Smart contracts from scratch — on-chain vs off-chain, validators, datum and redeemer, the tools to write and run them, then vesting, gift cards, oracles and testing." +description: "Smart contracts from scratch: on-chain vs off-chain, validators, datum and redeemer, the tools to write and run them, then vesting, gift cards, oracles and testing." --- import Tabs from "@theme/Tabs"; @@ -12,56 +12,45 @@ import TabItem from "@theme/TabItem"; You finished Beginner, so you can move value around Cardano. This track makes the chain **enforce rules** about how that value moves. That is what a smart contract is. We build up from the ideas to a real contract you compile and run yourself. :::note Coming from Ethereum? -"Smart contract" means something different here. On Cardano it is not a deployed program with storage that you call and that then acts. It is a **rule that answers yes or no** to a transaction your app already built. State still exists, but it lives in the **datum** on a UTxO rather than inside the contract. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers the rest. This track builds the model from scratch anyway. +"Smart contract" means something different here. On Cardano, it is not a deployed program with storage that you call and that then acts. It is a **rule that answers yes or no** to a transaction your app has already built. State still exists, but it lives in the **datum** on a UTxO rather than inside the contract. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers how Ethereum and Cardano development differ. This track teaches it from scratch. ::: ## What you'll be able to do After this track you'll be able to: -- Split any Cardano app into two halves: the part your code prepares, and the part the chain enforces. -- Read a validator and say what it lets through and what it rejects. -- Choose what goes in a datum and what goes in a redeemer, and explain why. -- Write a validator, compile it, and get a script address out of the blueprint. -- Build the transactions that lock funds at that address and unlock them again, collateral included. -- Run a contract you wrote end to end on the test network, and watch it refuse a spend that breaks its rule. -- Put a deadline on funds, mint a token from a contract, and change data that's already on the chain. -- Publish a contract once instead of carrying it in every transaction, and let one contract read another's data without consuming it. -- Test a contract properly before it ever holds anything real. +- Understand how Cardano dApps work under the hood and how you can build your own. +- Read and write Cardano smart contracts. +- Build transactions to interact with smart contracts. +- Connect to a protocol from your website. +- Understand how to work with time, redeemers, datums, and reference scripts. +- Test your contracts properly. +- Understand the architectural choices and implementations of 4 different protocols (vault, vesting, gift card, and oracle). ## The lectures -1. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** — the two halves: your app prepares and the chain enforces. -2. **[Set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)** — a language and compiler, and the contract project the next six lectures fill. -3. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** — a yes/no gatekeeper that guards a locked UTxO. -4. **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** — the data you hand a contract. -5. **[The transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)** — everything else a contract can look at before it decides. -6. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** — tracing, unit tests and property-based tests, so the next two lectures can change the contract safely. -7. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** — a value built into the contract itself, before it has an address. -8. **[Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** — spend and mint, under one script hash. +1. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**: what a dApp is made of, and the line between your code and the network's rules. +2. **[Set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**: install the compiler and start a brand new project for the next six lectures. +3. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**: what a validator is, how it works, and what you get when you compile one. +4. **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: the data you hand a contract. +5. **[The transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**: everything else a contract can look at before it decides. +6. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)**: tracing, unit tests and property-based tests. +7. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**: a value built into the contract itself, before it has an address. +8. **[Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**: spend and mint, under one script hash. +9. **[Off-chain and frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**: derive the address, build every transaction, prove them offline, then connect a wallet and drive the vault from a page in the browser. -Those eight are the contract, and nothing after them changes it. The ninth is the other half, all at once: +Lectures 10 to 12 each start from an idea and walk the same path, from the idea to the design to the code. Lecture 13 is a feature rather than a use case, and it is what lets contracts share code and data: -9. **[Off-chain and frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** — derive the address, build every transaction, prove them offline, then connect a wallet and drive the vault from a page in the browser. +10. **Handling time** (vesting): funds that can't move before a date, enforced without the contract ever reading a clock. +11. **Multi validators** (a gift card): one script guarding two different actions at once, minting and spending. +12. **Modifying state** (an oracle): changing data that's already on the chain. +13. **Reference inputs & reference scripts**: publish a contract once, and let one contract read another's data without consuming it. -The last four are what you build with the machine: +## The projects you'll build -10. **Handling time: vesting** — funds that can't move before a date, enforced without the contract ever reading a clock. -11. **Multi validators: a gift card** — one script guarding two different actions at once, and minting from a contract. -12. **Modifying state: an oracle** — changing data that's already on the chain. -13. **Reference inputs & reference scripts** — publish a contract once, and let one contract read another's data without consuming it. +Four contracts, and you write all of them: a **vault** that releases funds only to the owner who signs, a **vesting** contract that holds funds until a date, a **gift card** whose token is the key to the funds behind it, and an **oracle** that publishes a value and keeps changing it. -## You write the vault, one lecture at a time - -Lectures 1 to 9 build **one worked example**: a vault that locks funds and only releases them to the owner who signs. You do not read it, you write it. - -**The contract comes first, and it comes alone.** Lectures 2 to 8 are on-chain only: you write the validator, compile it, test it and finish it, with no app yet. The whole off-chain half is lecture 9. That is deliberate. The contract is where the thinking is, and it changes with every idea in the track, while the app that drives it is nearly the same code every time. - -Lectures 1 and 2 set up your workspace, `cardano-vault/`, with a half for each side, and leave you inside the contract project at `on-chain/vault/`. You stay there through lecture 8, so every Aiken command is the short kind: `aiken check`, `aiken add`, `aiken build`, with no paths to get wrong. Lecture 9 steps back up to the root, and that is the last folder change in the track. In between, each lecture explains one idea and has you add it to your contract: a validator that says yes to everybody, then the datum and redeemer, then the rule itself, then tests to hold it still, then a backup key, and finally a **mint** purpose so the vault can create its own token. - -Each step is a few lines and one command, and each one ends with a clean `aiken check`. From **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** onwards it also ends with a passing test suite, which is what makes the two lectures after it safe: both change a contract that already works. - -The four lectures after that work differently. Those contracts arrive finished, and the exercises have you break one and write the missing rule back. +The vault is the long one: lectures 1 to 9 build it a step at a time, one concept per lecture. Lectures 2 to 8 are on-chain only, so you write the validator, compile it and test it with no app yet, and lecture 9 is where you connect it to a website. That order is deliberate: the contract is where the thinking is, and the app that drives it follows from it. ## What you need @@ -87,12 +76,12 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o ## The playground {#the-playground} -Everything in these lectures is also finished and working in one example project, which we call the **playground**. It has every contract in the track, plus a small browser app that drives them: connect a wallet, mint and lock funds, unlock them again, put a deadline on funds, update an oracle. +Everything in these lectures is also finished and working in one example project, which we call the **playground**. It has every contract in the track, plus a small browser app that drives them: connect a wallet, mint and lock funds, unlock them again, put a deadline on funds, update an oracle, etc. You do not need it to follow the lectures. It is here for two reasons: - **To see where you are going.** Run it once now, and the rest of the track is you rebuilding the first part of it yourself. -- **To get unstuck.** Every exercise says the same thing at the end: the finished code is here. +- **To get unstuck.** Every exercise solution is provided in the playground's code. Download it, and start the app: @@ -118,8 +107,6 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming -Open the printed URL **in the browser where Lace is installed**. Connect, set up collateral, then **Lock 5 ADA** and **Unlock** it again. The **Mint & lock** button does the same thing but also creates a token under the contract's own policy, which is what **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is about. - Inside the folder, one directory per contract, and the code you read in these lectures is imported straight from it: ``` @@ -147,4 +134,6 @@ cp vault/off-chain/mesh/.env vesting/off-chain/mesh/.env Lectures 10 to 13 work directly in these folders, with `playground/` as the folder you run from: a different workspace, named on every command. Lectures 1 to 9 do not: there you build your own, and `playground/vault/` is the answer sheet. +Once `npm run dev` is running, open the printed URL **in the browser where Lace is installed**. Connect, set up collateral, then **Lock 5 ADA** and **Unlock** it again. The **Mint & lock** button does the same thing but also creates a token under the contract's own policy, which is what **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is about. + Ready? Start with **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**. From f7c73bd3f6deea5905b4664f57fa707ff73d0349 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:55:00 -0300 Subject: [PATCH 11/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index c6cb4723d1..df3c14e9f7 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -9,7 +9,7 @@ import TabItem from "@theme/TabItem"; # What a validator is -A smart contract on Cardano is a **validator**: a small function the network runs when a transaction tries to do something that validator guards. Spending a **locked** UTxO is the most common case, and the one this lecture uses. Minting is another, and your vault gains that purpose in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the action is allowed. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. +The simplest smart contract on Cardano is a **validator**: a small function the network runs when a transaction tries to do something that validator guards. Spending a **locked** UTxO is the most common case, and the one this lecture uses. Minting is another, and your vault gains that purpose in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the action is allowed. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. Here is the part that surprises people: **a validator never moves funds.** Think of it as a **guard at a door** rather than a program that holds money and pays it out. A guard does not carry anything in or out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. It works the same way here. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. From dcf4406e1b3aac71c0fc958baac00e5b43910c05 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:55:43 -0300 Subject: [PATCH 12/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index df3c14e9f7..5fc578a003 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -19,7 +19,7 @@ So a validator is defined by what it **refuses**. A guard who lets everyone thro "A smart contract" does not always mean *one* validator. A real application often uses several. Each one protects its own thing, and each one judges the same transaction on its own, without ever calling the others. -What ties them together is a single rule: **every validator the transaction triggers has to say yes.** One no anywhere, and the whole transaction is rejected. That is how contracts cooperate on Cardano, by each making its own demand of the same transaction. +What ties them together is a single rule: **every validator the transaction triggers has to say yes.** If a single validator rejects it, the whole transaction is rejected. That is how contracts cooperate on Cardano, by each making its own demand of the same transaction. Our examples use a single validator for now. **Multi validators** shows one script guarding two different actions at once, and **reference inputs** shows two separate contracts working together. From e1e91ededa6216b5e241bef1c87d10cd0faf9762 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:56:12 -0300 Subject: [PATCH 13/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 5fc578a003..95d24d25bd 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -21,7 +21,7 @@ So a validator is defined by what it **refuses**. A guard who lets everyone thro What ties them together is a single rule: **every validator the transaction triggers has to say yes.** If a single validator rejects it, the whole transaction is rejected. That is how contracts cooperate on Cardano, by each making its own demand of the same transaction. -Our examples use a single validator for now. **Multi validators** shows one script guarding two different actions at once, and **reference inputs** shows two separate contracts working together. +The next few examples use a single validator, but we'll cover more complex contracts later. ## Where the locked funds live From 8d61c89826c57ba06b69c7d636387a27e2d2c847 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:56:52 -0300 Subject: [PATCH 14/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 95d24d25bd..e140ff4541 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -27,7 +27,7 @@ The next few examples use a single validator, but we'll cover more complex contr Remember from Beginner that a [UTxO](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) (a "sealed bag") always sits at an **[address](/docs/developers/onboarding/lectures/beginner/wallets-keys-addresses)**. Most of the addresses you have used belong to a person. These are **key addresses**, and whoever holds the matching private key can spend what is there. -You have already met the other kind. When Bob locked 5 ADA behind a native script in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata), the funds went to a **script address**, controlled by a **rule** instead of a person. A validator uses the same kind of address. The only difference is how complex the rule is allowed to be. +You have already met the other kind. When Bob locked 5 ADA behind a native script in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata), the funds went to a **script address**, controlled by **a set of rules** instead of a person. A validator uses the same kind of address. The only difference is how complex the rules can be. Validators allow for arbitrarily complex logic (as long as you're within the transaction's budget). ```mermaid flowchart TB From a321cba3bc0fbe9814d291962563e8c0fc1ce960 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:57:15 -0300 Subject: [PATCH 15/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index e140ff4541..94c6cd4de0 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -35,7 +35,7 @@ flowchart TB KA["10 ADA sitting here"] --> KR["to spend it:
sign with the matching private key"] end - subgraph S["Script address: controlled by a rule"] + subgraph S["Script address: controlled by the validator"] SA["5 ADA sitting here"] --> SR["to spend it:
build a transaction the validator approves"] end From 1fb0045d2824a386116aa76a6fc70cffafb04c51 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 12:58:07 -0300 Subject: [PATCH 16/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 94c6cd4de0..7ce1c1f2d0 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -42,7 +42,7 @@ flowchart TB K ~~~ S ``` -Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. The only difference is what it takes to open them. A key address asks _"is this signed by the right key?"_. A script address asks _"does the validator say yes?"_. A script address has no key, no recovery phrase, and nobody who can give permission. Even the person who wrote the contract has to satisfy the rule like everyone else. +Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. The only difference is what it takes to open them. A script address has no key, no recovery phrase, and nobody who can give permission. Even the person who wrote the contract has to satisfy the rule like everyone else. Where does that address come from? From the validator itself, using the same hashing you saw there. You hash the compiled contract, and that fingerprint becomes the address. Change one character of the contract and you get a completely different address, guarding completely different funds. You will do exactly this, in three calls, in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. From 22b1dd5fab98183fc78a1c57ec659279705c135e Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:00:51 -0300 Subject: [PATCH 17/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 7ce1c1f2d0..6935055f01 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -44,7 +44,7 @@ flowchart TB Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. The only difference is what it takes to open them. A script address has no key, no recovery phrase, and nobody who can give permission. Even the person who wrote the contract has to satisfy the rule like everyone else. -Where does that address come from? From the validator itself, using the same hashing you saw there. You hash the compiled contract, and that fingerprint becomes the address. Change one character of the contract and you get a completely different address, guarding completely different funds. You will do exactly this, in three calls, in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. +Where does that address come from? From the validator itself, using the same hashing you saw there. You hash the compiled contract, and use that fingerprint to derive the address. Change one character of the contract, and you get a completely different address that guards completely different funds. You will do exactly this in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. ## Locking is just a payment From 7964f27929f54a862b23df219b855193be122918 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:04:24 -0300 Subject: [PATCH 18/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 6935055f01..649f5fcea7 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -48,7 +48,7 @@ Where does that address come from? From the validator itself, using the same has ## Locking is just a payment -Here is the part that catches almost everyone out: **the contract does not run when you lock funds.** +A very important detail: **the validator does not run when you lock funds (create a UTxO in its address).** Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The network runs nothing, because there is nothing to approve. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. The contract does not run at all. From d807d98c479926b17111ee1708611822172cff25 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:08:54 -0300 Subject: [PATCH 19/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 649f5fcea7..b5ab35f75c 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -50,7 +50,7 @@ Where does that address come from? From the validator itself, using the same has A very important detail: **the validator does not run when you lock funds (create a UTxO in its address).** -Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The network runs nothing, because there is nothing to approve. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. The contract does not run at all. +Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. The contract does not run at all. It runs only when someone tries to **spend** that UTxO. At that moment the network takes the validator, gives it the transaction, and asks its one question. From 97ad912a58a71b1e804e63073dff56b291405c64 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:11:05 -0300 Subject: [PATCH 20/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 564ab6ce05..e3ebba6c08 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -103,7 +103,7 @@ The context is generous, but it stops at the edge of one transaction. A contract All of these come back to the same rule from **[on-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**: every node must reach the **same answer, forever**. Anything that could differ between two nodes is left out. :::tip The transaction is the whole world -A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums, the UTxOs it references, its outputs, its signatures, its window. No API to call, no database to query, not even the block it sits in. What is not in the transaction does not exist as far as the contract is concerned. +A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums and values, the UTxOs it references, its outputs, its signatures, its window, etc. No API to call, no database to query, not even the block it sits in. What isn't in the transaction doesn't exist, as far as the contract is concerned. So a contract never gathers facts, it only judges the ones already in front of it, and **whoever builds the transaction has to put them there**. That is what the datum, the redeemer and the reference inputs are for. The question is never "how does the contract fetch this", it is "who puts it in, and why should the contract believe them". **Modifying state** builds an oracle, which is that question answered. ::: From fecdf0d9bbbeb69a7e88c2aa35eea8fe44feae2a Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:19:28 -0300 Subject: [PATCH 21/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index e3ebba6c08..ffef5e6daf 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -100,7 +100,6 @@ The context is generous, but it stops at the edge of one transaction. A contract - **the rest of the block**: other transactions being confirmed at the same moment are invisible. - **the metadata**. This one surprises people, because you attached metadata to a transaction back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It is stored on the chain and anyone can read it, but scripts are not shown it. So a contract can never enforce a rule about metadata. -All of these come back to the same rule from **[on-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**: every node must reach the **same answer, forever**. Anything that could differ between two nodes is left out. :::tip The transaction is the whole world A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums and values, the UTxOs it references, its outputs, its signatures, its window, etc. No API to call, no database to query, not even the block it sits in. What isn't in the transaction doesn't exist, as far as the contract is concerned. From b681ae63d1a48a4d8159c0cfdc39bc563a09c781 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:19:51 -0300 Subject: [PATCH 22/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index ffef5e6daf..61fd555ade 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -116,7 +116,7 @@ So a contract never gathers facts, it only judges the ones already in front of i Everything below runs from `on-chain/vault/`, where lecture 2 left you. -The rule in words: **allow the spend only if the owner named in the datum is among the keys the transaction requires a signature from.** You have every piece. `self` is the transaction, `self.extra_signatories` is that list, `owner` came out of the datum last lecture, and `list.has` answers whether something is in a list. +What we check: **allow the spend only if the owner named in the datum is among the keys the transaction requires a signature from.** You have every piece. `self` is the transaction, `self.extra_signatories` is that list, `owner` came out of the datum last lecture, and `list.has` answers whether something is in a list. In `validators/vault.ak`, make three changes: From 0b8aa304974ee03f49695bf340d48cf2f9f731f6 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:21:57 -0300 Subject: [PATCH 23/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index b5ab35f75c..9c86dc4df0 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -76,7 +76,7 @@ validator(datum, redeemer, context) -> True | False - **redeemer** what the spender provides when unlocking, - **context** the whole transaction around it. -In code you will see **four** arguments rather than three, because the context arrives in two pieces: the transaction, and a pointer to the exact UTxO being spent. The idea is still these three. +Depending on the language you choose to write your validators in, you can see more or fewer arguments. That is just the language trying to be convenient for you. At the end of the day, everything is inside the transaction context. It is **handed** nothing else. No network access, no clock, no storage, and nothing about the world beyond what it is given. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next two lectures cover all three in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds the one route that does not go through this list at all. For now, remember the shape: **information in, one yes or no out.** From 3212c58c98213e4f0c83bb98174f224dc61cfdc1 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:23:34 -0300 Subject: [PATCH 24/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 9c86dc4df0..962f495611 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -62,7 +62,7 @@ flowchart LR V -->|no| No["transaction rejected,
the UTxO stays put"] ``` -So a validator only checks funds on the way **out**, never on the way in. Anyone can send funds in, even by mistake, and nothing checks them. Taking them out is the only guarded step. This matters more than it first appears, and it is the shape of every contract in this track. You lock first, and all the interesting logic happens at the spend. +So a validator only checks funds on the way **out**, never on the way in. That's why it's called a "spending validator" (we'll explain more in the [purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes) lecture). Anyone can send funds in, even by mistake, and nothing checks them. Only taking them out is guarded. This matters more than it first appears, since it affects how you write your logic. You lock first, and all the interesting logic happens at the spend. ## What the validator sees From 1f287dfd93e4ea59365a06c44f0d7fe93770eeec Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:27:34 -0300 Subject: [PATCH 25/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 962f495611..fd1dd2df1d 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -78,7 +78,7 @@ validator(datum, redeemer, context) -> True | False Depending on the language you choose to write your validators in, you can see more or fewer arguments. That is just the language trying to be convenient for you. At the end of the day, everything is inside the transaction context. -It is **handed** nothing else. No network access, no clock, no storage, and nothing about the world beyond what it is given. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next two lectures cover all three in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds the one route that does not go through this list at all. For now, remember the shape: **information in, one yes or no out.** +The validator cannot access anything else. No network access, no clock, no storage, and nothing about the world beyond what it is given. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next lectures cover all three inputs in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds a way to hardcode values directly into the validator. :::warning A validator is only as good as what it refuses Think about the two simplest validators possible, and you will see the full range you are working in: From 848bc164c671bd23dd10a62022065230a6f48b83 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:30:01 -0300 Subject: [PATCH 26/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index fd1dd2df1d..73a4f92a0a 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -114,7 +114,7 @@ validator vault { `validator vault` names the script. `spend` is the handler that runs when someone tries to spend a locked UTxO. The underscore in front of each name means "given, but not used here", so this contract ignores everything it is handed. -Four arguments, three ideas. `_datum` and `_redeemer` are the first two from the list above. The context is the other two together: `_own_ref` points at the UTxO being spent, and `_self` is the whole transaction. What is inside it is the subject of **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. +Four arguments, three ideas. `_datum` and `_redeemer` are the first two from the list above. `_own_ref` points at the UTxO being spent, and `_self` is the whole transaction context. What is inside it is the subject of **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. The body is the entire rule: `True`, yes to everybody. From afab14b2638b5a2c693cda75b5200fa482c22b80 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:31:16 -0300 Subject: [PATCH 27/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 73a4f92a0a..01ee423376 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -118,6 +118,7 @@ Four arguments, three ideas. `_datum` and `_redeemer` are the first two from the The body is the entire rule: `True`, yes to everybody. +Run in your terminal: ```bash aiken check ``` From b819c9b404f415ac4ae2dc261304c5125a1bdc7a Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 13:31:48 -0300 Subject: [PATCH 28/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 01ee423376..eb52c7b9a0 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -96,7 +96,7 @@ Real people have shipped both of these by mistake. A contract that always passes -Everything below runs from `on-chain/vault/`, where lecture 2 left you. +Everything below runs inside `on-chain/vault/`, where lecture 2 left you. Now the contract itself: the smallest one that compiles, and it says yes to everything. Create the file `validators/vault.ak` and put this in it. Copy it as it is: **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** explains the arguments, and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** explains the `else` block. From 6da929c4d80732ecb3a34da93b6987c5b13e8592 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 26 Aug 2026 14:26:12 -0300 Subject: [PATCH 29/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 849da1f0fc..226260ec3d 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -61,7 +61,7 @@ Our example contract is a **vault**. It locks some funds so that only their owne Read it as two shapes being declared: -- `VaultDatum` has a single field, `owner`, of type `VerificationKeyHash`. That is a **public key hash**, the short fingerprint of a public key. Native scripts used the same thing to name a signer back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It says who must sign, but it is not a key itself. +- `VaultDatum` has a single field, `owner`, of type `VerificationKeyHash`. That is a **public key hash**, the short fingerprint of a public key. Native scripts used the same thing to name a signer back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). - `VaultAction` has a single choice, `Unlock`. A larger contract would list several, such as `Unlock`, `Cancel` and `Extend`, and the validator would check which one the spender chose. From 482f377453c6ff0725432dd16a92330223eb35a9 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:46:34 -0300 Subject: [PATCH 30/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index d4cc75d914..cc39771ce6 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -18,7 +18,7 @@ So the question is not whether to test. It is how to be sure **before** anything [Last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) you wrote a real rule, and every `aiken check` you have run so far has only **compiled** it. The compiler proves the contract is valid Aiken. It cannot tell you whether the rule you wrote is the rule you meant. Your vault would compile just as happily with `list.has` replaced by `True`. -That is this lecture. It arrives here, in the middle of the track rather than at the end, because everything after it changes the rule: **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** adds a second door and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** adds a mint. Each of those is a change to a contract that already works, and the way you find out you broke something is a test you wrote before you started. +Here's an overview of the types of verifications we could do to check if our contract behaves as we expect, ordered from simples/less accurate to more complex/more accurate: ```mermaid flowchart LR From 2aaa605c69dbfb3efdde5765c04059c817a7c355 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:48:35 -0300 Subject: [PATCH 31/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index cc39771ce6..43ad805edd 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -89,7 +89,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o Unit tests only check the cases you thought of. Your two name one owner, a key you picked. But the rule is not about that key. It is about **any** key: whoever the datum names must be the one who signed. -A **property test** states that rule directly and lets the test runner go looking for an example that breaks it. Instead of the one key you chose, it generates a hundred and tries every one. +A **property test** states a property directly and lets the test runner find an example that breaks it. Instead of the one key you chose, it explores the space, generating cleverly crafted counterexamples hundreds or thousands of times. If any of them fails, it does more than report it. It **reduces** the failing input to the smallest one that still breaks, so you get the exact edge case rather than whichever random value happened to fail first. From 89e594f883a7adcc1b2e3efb95656db2535980d0 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:48:52 -0300 Subject: [PATCH 32/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 43ad805edd..9f5be89048 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -91,7 +91,7 @@ Unit tests only check the cases you thought of. Your two name one owner, a key y A **property test** states a property directly and lets the test runner find an example that breaks it. Instead of the one key you chose, it explores the space, generating cleverly crafted counterexamples hundreds or thousands of times. -If any of them fails, it does more than report it. It **reduces** the failing input to the smallest one that still breaks, so you get the exact edge case rather than whichever random value happened to fail first. +If any of them fails, it does more than report it. It **reduces ("shrinks")** the counterexample to the smallest one that still breaks the property, so you get the exact edge case rather than whichever random value happened to fail first. A property test is worth reaching for whenever a rule holds "for all" of something: every key, every amount, every moment after a deadline. You will meet that last one in **handling time**, where the vesting contract arrives with `claim_ok_at_any_time_after_the_deadline` already written. From 14b1a592e61087ba0653bdfdeae488675b996c67 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:49:11 -0300 Subject: [PATCH 33/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 9f5be89048..ca73d64c5b 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -101,7 +101,7 @@ Both levels above test the validator **on its own**, and that is also their limi Many things go wrong in the gap between those two: a datum built with the wrong constructor number, a missing required signer, a redeemer that does not match. None of these are contract bugs, none of them appear in a contract test, and your vault can be perfect while your app is still unable to open it. -Closing that gap needs an app to test, so it is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does once there is one: build the **real transaction** with your real off-chain code, then run the **real compiled validator** against it, with no network at all. +Closing that gap needs off-chain for integration testing, so it is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does once there is one: build the **real transaction** with your real off-chain code, then check the transaction against a real or simulated node. ## Try it From c648e40c256e90cf83a7bf4fab8f241a0890782f Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:50:08 -0300 Subject: [PATCH 34/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index ca73d64c5b..e6a636d338 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -27,7 +27,7 @@ flowchart LR The first two are this lecture, and they need nothing but the contract. The third needs an app to test against, so it waits for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. -## Unit tests: the cases you thought of +## Unit tests The cheapest test builds a **fake transaction**, hands it to the validator, and checks the answer. No network, no wallet, no test ADA, and it finishes in milliseconds. From b162bc430b58800756461e8ee0b1d06a469b762e Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 10:50:39 -0300 Subject: [PATCH 35/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index e6a636d338..2bf946c6c5 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -25,7 +25,7 @@ flowchart LR U["unit tests
one case you thought of"] --> P["property tests
one rule, a hundred inputs"] --> S["scenario tests
the whole transaction,
never submitted"] ``` -The first two are this lecture, and they need nothing but the contract. The third needs an app to test against, so it waits for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. +In this lecture, we'll cover Unit and Property testing, since they only need the contract. Integration testing requires building and submitting transactions, so we'll wait for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and Formal Methods is for when you can write protocols with your eyes closed. So, we won't cover those during the onboarding ## Unit tests From 1f18faed02c85f809795dbf5f3e595c0068f52ec Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:05:32 -0300 Subject: [PATCH 36/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 2bf946c6c5..1b5ee9e503 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -31,7 +31,7 @@ In this lecture, we'll cover Unit and Property testing, since they only need the The cheapest test builds a **fake transaction**, hands it to the validator, and checks the answer. No network, no wallet, no test ADA, and it finishes in milliseconds. -Your vault has one rule, so it needs two tests: one person who should get through, and one who should not. +Your vault has one real check, so it needs two tests: one for when the transaction should get through, and one for when it shouldn't. That second one is the one that matters. Notice the balance: half of these check a **refusal**, and that is the habit worth copying for every contract in this track. **A validator is defined by what it rejects.** A validator that always said yes would pass every success test you could write, which is why a suite of nothing but success tests tells you almost nothing. From 8a45c7f46045b13741c4179ad95c4c10ea3c125d Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:05:55 -0300 Subject: [PATCH 37/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 1b5ee9e503..37a5c05b5d 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -85,7 +85,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o
-## Property tests: the cases you didn't +## Property tests Unit tests only check the cases you thought of. Your two name one owner, a key you picked. But the rule is not about that key. It is about **any** key: whoever the datum names must be the one who signed. From c655c3531a9e868cc01af4abfaef5303a6ab7dff Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:07:19 -0300 Subject: [PATCH 38/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 37a5c05b5d..fd135643ab 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -12,7 +12,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme # Testing -A contract has no update button. Once funds sit behind it, a mistake means value lost or given away, and no patch can take it back. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** put it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. +You can't update a validator. Once funds sit behind it, a mistake means lost or given-away value, and no patch can take it back. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** puts it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. So the question is not whether to test. It is how to be sure **before** anything real is at risk. From 1901770a8a9608cc30dde8e3a93ec343a06e4f78 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:08:36 -0300 Subject: [PATCH 39/89] Update docs/developers/onboarding/lectures/intermediate/6-testing.md Co-authored-by: Robertino --- docs/developers/onboarding/lectures/intermediate/6-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index fd135643ab..e31ace72b9 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -16,7 +16,7 @@ You can't update a validator. Once funds sit behind it, a mistake means lost or So the question is not whether to test. It is how to be sure **before** anything real is at risk. -[Last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) you wrote a real rule, and every `aiken check` you have run so far has only **compiled** it. The compiler proves the contract is valid Aiken. It cannot tell you whether the rule you wrote is the rule you meant. Your vault would compile just as happily with `list.has` replaced by `True`. +[In the last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context), you wrote a real validator, and every `aiken check` you have run so far has only **compiled** it. The compiler proves the contract is valid Aiken. It cannot tell you whether the checks you wrote are the logic you meant. Your vault would compile just as happily with `list.has` replaced by `True`. That is what we explore in this lecture. Here's an overview of the types of verifications we could do to check if our contract behaves as we expect, ordered from simples/less accurate to more complex/more accurate: From 6842d419b846826a647a88f28f3e372db8cbc39a Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:09:18 -0300 Subject: [PATCH 40/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index eb52c7b9a0..cf8d31dd39 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -123,7 +123,7 @@ Run in your terminal: aiken check ``` -It compiles. Now change `True` to `False` and run it again. The result is **identical**: no error, no warning. Both are valid contracts. One gives the funds to whoever asks first, the other locks them away from everyone forever, and the compiler has no opinion about either. Only you decide what your contract refuses. +It type-checks. Now change `True` to `False` and run it again. The result is **identical**: no error, no warning. Both are valid contracts. Put `True` back, and compile it for real: From 4456e2b9ae88efdf088db817c0d0864598032d96 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:09:41 -0300 Subject: [PATCH 41/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index cf8d31dd39..7f7c92e303 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -151,7 +151,7 @@ Stuck? The finished code is in the playground. See the **[introduction](/docs/de ## What compiling produced -Compiling wrote **`plutus.json`**, next to `aiken.toml`. This is the **blueprint**: the compiled contract, described in a format every Cardano language shares. Your off-chain code reads this file and turns it into an address, which you will see done in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. +Compiling wrote **`plutus.json`**. This is the **blueprint**: the compiled contract, described in a format all Cardano languages share. Your off-chain code reads this file and turns it into an address, which you will see done in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. Open it. Four things are inside: From fcf2e2b594fbeb5a0fe9a6b63d76626d3c906f4f Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:10:23 -0300 Subject: [PATCH 42/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 7f7c92e303..6a0bdef870 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -155,7 +155,7 @@ Compiling wrote **`plutus.json`**. This is the **blueprint**: the compiled contr Open it. Four things are inside: -- **`preamble`:** who built it, with which compiler, and which Plutus version (`v3` here). +- **`preamble`:** who built it, with which compiler, and which Plutus version. - **`validators[]`:** one entry per **purpose**, titled `file.validator.purpose`. Yours has two, `vault.vault.spend` and `vault.vault.else`, and they share one `hash`. That hash is the fingerprint from earlier in this lecture: the contract's identity, and the value its address is built from. Why one script has several entries under it is the subject of **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. - **`compiledCode`:** the actual program, as a hex string. This is the **only** part the network ever runs. It is a low-level language called UPLC, and every contract language compiles down to it. - **`definitions`:** the shapes of your datum and redeemer types, which is [the next lecture](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer). Right now they are just `Data`, because your validator accepts anything. From a42b3269951f962a5ef76b5b125d131189b43a64 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:11:23 -0300 Subject: [PATCH 43/89] Update docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/3-what-is-a-validator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 6a0bdef870..9c9239a5c6 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -160,7 +160,7 @@ Open it. Four things are inside: - **`compiledCode`:** the actual program, as a hex string. This is the **only** part the network ever runs. It is a low-level language called UPLC, and every contract language compiles down to it. - **`definitions`:** the shapes of your datum and redeemer types, which is [the next lecture](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer). Right now they are just `Data`, because your validator accepts anything. -Notice what is **not** in there: the address. It is built from the hash, and it depends on which network you are on. +Notice what is **not** in there: the address. It is built from the hash and depends on other factors, like which network (testnet or mainnet) you're using. ## Go deeper From 9a6fe35ac543b1750f364fe2414a1ee75c8102c7 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:11:57 -0300 Subject: [PATCH 44/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 226260ec3d..39cd0157a2 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -14,7 +14,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme [Last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator) said a validator is a function of **datum**, **redeemer**, and **context**. The validator you wrote ignores all three. This lecture is about the first two, which are how you give information to a contract. They are also the part newcomers most often confuse, so they are worth explaining carefully. -- The **datum** is information **attached to the locked UTxO** when you lock it. These are the _terms_. Think of it as a note that says "this is locked under these conditions". It is fixed the moment the funds are locked and never changes. +- The **datum** is information attached to the UTxO when you **lock it**. You can put anything in there. Addresses, numbers, information, anything! The important part is that it is fixed the moment the funds are locked and never changes. - The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt, and it is supplied fresh in the spending transaction. - The **context** is the rest of the transaction: its inputs, outputs, signatures, and the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano). The validator can read all of it. There is enough of it to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. From 658d4943a2b80267869e6c4120b04bcfa9cc5ce1 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 11:12:32 -0300 Subject: [PATCH 45/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 39cd0157a2..99a00bb98b 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -16,7 +16,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme - The **datum** is information attached to the UTxO when you **lock it**. You can put anything in there. Addresses, numbers, information, anything! The important part is that it is fixed the moment the funds are locked and never changes. - The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt, and it is supplied fresh in the spending transaction. -- The **context** is the rest of the transaction: its inputs, outputs, signatures, and the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano). The validator can read all of it. There is enough of it to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. +- The **context** is the rest of the transaction: its inputs, outputs, signatures, the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano), and more. The validator can read all of it. There is enough info to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. Imagine you leave a bag with someone for safe keeping. That bag is a **UTxO**. When you hand it over, they attach a note that says "give this back only to the person holding ticket 42". That note stays with the bag, and it is the **datum**. Later somebody arrives and says what they want: "I am here to collect the bag." That request is the **redeemer**. The note alone decides nothing, and the request alone decides nothing. The decision needs both together, plus the situation they arrive in, which is the context. From abac7c64c8682c6ead23b42fc66dafa52f906e07 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 12:51:13 -0300 Subject: [PATCH 46/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 99a00bb98b..38c6a0b88b 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -62,7 +62,7 @@ Our example contract is a **vault**. It locks some funds so that only their owne Read it as two shapes being declared: - `VaultDatum` has a single field, `owner`, of type `VerificationKeyHash`. That is a **public key hash**, the short fingerprint of a public key. Native scripts used the same thing to name a signer back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). -- `VaultAction` has a single choice, `Unlock`. A larger contract would list several, such as `Unlock`, `Cancel` and `Extend`, and the validator would check which one the spender chose. +- `VaultAction` has a single choice, `Unlock`. A larger contract would list several, such as `Unlock`, `Cancel`, and `Extend`; the validator would check which one the spender chose and adjust its checks accordingly. From 59f5ffaa0714ed60a4611e10b3f2d53d0fc10e4c Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 12:51:39 -0300 Subject: [PATCH 47/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 38c6a0b88b..fe00875ad9 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -15,7 +15,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme [Last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator) said a validator is a function of **datum**, **redeemer**, and **context**. The validator you wrote ignores all three. This lecture is about the first two, which are how you give information to a contract. They are also the part newcomers most often confuse, so they are worth explaining carefully. - The **datum** is information attached to the UTxO when you **lock it**. You can put anything in there. Addresses, numbers, information, anything! The important part is that it is fixed the moment the funds are locked and never changes. -- The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt, and it is supplied fresh in the spending transaction. +- The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt (transaction), and they supply it fresh in the spending transaction. - The **context** is the rest of the transaction: its inputs, outputs, signatures, the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano), and more. The validator can read all of it. There is enough info to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. Imagine you leave a bag with someone for safe keeping. That bag is a **UTxO**. When you hand it over, they attach a note that says "give this back only to the person holding ticket 42". That note stays with the bag, and it is the **datum**. Later somebody arrives and says what they want: "I am here to collect the bag." That request is the **redeemer**. The note alone decides nothing, and the request alone decides nothing. The decision needs both together, plus the situation they arrive in, which is the context. From 0ae7176a31122b47100f8d4dfdd2e1cf010ebb44 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:01:03 -0300 Subject: [PATCH 48/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../intermediate/4-datum-and-redeemer.md | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index fe00875ad9..df40de8f8f 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -24,23 +24,22 @@ So: **datum is what was set at lock time, and redeemer is what the spender says ```mermaid sequenceDiagram - participant You as Your app + wallet - participant Net as Network - participant Vault as The script address
(no wallet, no keys, no owner) - - Note over You,Vault: Transaction 1, locking - You->>Net: sign + submit a payment to the script address,
with the datum attached - Net->>Vault: an ordinary payment, accepted. The 5 ADA sits here - Note over Net: the validator does not run:
nothing is being unlocked yet - Note over Vault: 5 ADA + datum (the terms): owner = your key hash - - Note over You,Vault: later. Anyone may try to spend that UTxO - - Note over You,Vault: Transaction 2, unlocking - You->>Net: sign + submit a spend of that UTxO,
with the redeemer attached - Net->>Net: run the validator on three things:
the datum (read off the UTxO), the redeemer (from this transaction),
and the context (this transaction itself) - Net-->>You: True, or False - Note over Net,You: True → the 5 ADA moves where transaction 2 says
False → transaction 2 is rejected, the UTxO stays put + participant You as Your app + participant Car as Cardano + + Note over You,Car: Transaction 1, locking + You->>Car: sign + submit a payment to the script address,
with the datum attached + Note over Car: an ordinary payment, accepted. The 5 ADA sits in the Vault's address in a new UTxO. The validator does not run:
nothing is being unlocked yet + Note over You,Car: Transaction 2, unlocking + You->>Car: sign + submit to consume the UTxO from the script address,
providing the redeemer + Car->>Car: Run the validator providing:
the datum (read off the UTxO), the redeemer (from this transaction),
and the context (this transaction itself) + + alt validator acepted + Car->>Car: Transaction applied to the blockchain + Car->>You: Transaction accepted + else validator rejected + Car->>You: Blockchain rejected the transaction + end ``` Two transactions, and only the second one is judged. Everything the **datum** says was settled in From 9c9f302cdce2c68859da759cc66aa55aaae964ef Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:03:12 -0300 Subject: [PATCH 49/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index df40de8f8f..e9f0754ce9 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -137,7 +137,7 @@ Then write the datum and the redeemer themselves, **between the imports and the {extractRegion(VaultSimple, "types")}
-Last, **replace the whole `validator` block** with this one. The rule has not changed, it still ends in `True`, but the handler now says what it expects to be handed, and reads the owner out of it: +Lastly, **replace the whole `validator` block** with this one. The contract behavior changed slightly: it still always allows anyone to spend the UTxO because it ends in `True`, but only if the datum has the expected shape (`VaultDatum`). ```aiken title="validators/vault.ak" validator vault { From 1aeb386ea660487c38faf0c5a27bbf3ed12a0658 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:03:58 -0300 Subject: [PATCH 50/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index e9f0754ce9..73bd781829 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -163,7 +163,7 @@ Save it, and: aiken check ``` -Green. Two lines are worth a moment, because they are doing more than they look: +Everything should be working, but **what changed?**: - `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. - `expect Some(VaultDatum { owner }) = datum` means "there must be a datum, it must be a `VaultDatum`, and I want its `owner`". If any of that is untrue the validator fails and the spend is refused. This is the line the warning above describes: it is where a mismatched datum gets caught, long after it was attached. From 90c377acd17faa9bf5e9b89872794191356b38bf Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:05:05 -0300 Subject: [PATCH 51/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 73bd781829..f87eda694e 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -165,7 +165,7 @@ aiken check Everything should be working, but **what changed?**: -- `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. +- `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. However, if there is a datum, it'll be of type `ValutDatum`. - `expect Some(VaultDatum { owner }) = datum` means "there must be a datum, it must be a `VaultDatum`, and I want its `owner`". If any of that is untrue the validator fails and the spend is refused. This is the line the warning above describes: it is where a mismatched datum gets caught, long after it was attached. The contract still returns `True`, so it still gives the funds to anybody. But it now insists on being handed a note it can read, and it knows the owner. The [next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) is where that owner starts deciding things. From 67ee991a3494507e0405e64439583652a6cbb063 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:05:53 -0300 Subject: [PATCH 52/89] Update docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/4-datum-and-redeemer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index f87eda694e..0635b36084 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -193,7 +193,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -**And the redeemer?** You cannot watch it decide anything yet, and that is worth saying plainly rather than inventing a contract to hide it. `VaultAction` offers one choice, so every spender sends the identical thing and it changes no outcome. A redeemer only starts doing work once there is more than one action to pick from, which is what happens in **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. +**And the redeemer?** You cannot watch it decide anything yet. `VaultAction` offers only one choice, and it contains no data. So every spender sends the same thing, and it changes nothing. A redeemer only starts doing real work once there is more than one action to pick from or when it provides information inside, which will happen in **[a future lecture](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. From 050f0eb861b140ce352730864e4ae077ea55eb11 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:06:26 -0300 Subject: [PATCH 53/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 61fd555ade..fc883e6cc7 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -16,7 +16,7 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme The **context** is the **transaction itself**. When someone tries to spend your locked funds, the network hands your contract the entire transaction that is trying to do it, and lets the contract look at any part of it before answering. -So the datum and the redeemer are two small values you supply. The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent, what is being created, who signed, and the time window the transaction declared. +So the datum and the redeemer are two small values you supply. The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent and all their properties, which tokens are being minted and burned, who signed the transaction, the time window the transaction declared, etc. A rule that only compares the datum with the redeemer protects nothing. Both are data, and data cannot show who signed, what moved, or when it happened. Only the transaction shows that, which is why nearly every rule you write is a question about the transaction, measured against what the datum says. Your vault is about to ask exactly one: is the owner named in the datum among the keys that signed? From caa90d146f8dac1c3a4c6992b7ec73a0a1cc5779 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:07:25 -0300 Subject: [PATCH 54/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index fc883e6cc7..fca8494e0b 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -18,7 +18,7 @@ The **context** is the **transaction itself**. When someone tries to spend your So the datum and the redeemer are two small values you supply. The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent and all their properties, which tokens are being minted and burned, who signed the transaction, the time window the transaction declared, etc. -A rule that only compares the datum with the redeemer protects nothing. Both are data, and data cannot show who signed, what moved, or when it happened. Only the transaction shows that, which is why nearly every rule you write is a question about the transaction, measured against what the datum says. Your vault is about to ask exactly one: is the owner named in the datum among the keys that signed? +A validator that only compares the datum with the redeemer protects nothing. Both are data, and data cannot show who signed, what moved, or when it happened. Only the transaction shows that, which is why nearly every check you write is a question about the transaction, measured against what the datum says. Your vault is about to ask exactly one: is the owner named in the datum among the keys that signed? ## What is inside From fd4c924f515317526ec97c73db1f81cc33cc09af Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:08:27 -0300 Subject: [PATCH 55/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index fca8494e0b..01a54a5eaf 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -31,7 +31,7 @@ The context holds one transaction, described in full. Here is everything in it, | **Who and when** | `extra_signatories`, `validity_range` | the keys the transaction requires a signature from, and the time window it declared | | **The rest** | `certificates`, `withdrawals`, `redeemers`, `datums`, `id`, and the governance and treasury fields | staking, voting, the transaction's own id, and the datums and redeemers it carries | -Most contracts use the first three groups and never touch the fourth. +Depending on what your contract wants to check, you'll choose what to look at. :::note These names come from the ledger, not from a language The names above are spelled the way this track's examples spell them, and another language will write some of them a little differently. What the list holds is decided by **Cardano**, not by the tool you write your contract in, so learn it once and it carries over. From 1935838eca0f0a44f59c0fcbaaad8117d772b3c5 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:09:23 -0300 Subject: [PATCH 56/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 01a54a5eaf..c704bc62ef 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -36,7 +36,7 @@ Depending on what your contract wants to check, you'll choose what to look at. :::note These names come from the ledger, not from a language The names above are spelled the way this track's examples spell them, and another language will write some of them a little differently. What the list holds is decided by **Cardano**, not by the tool you write your contract in, so learn it once and it carries over. -The list also grows. Each version of the on-chain language has added fields: `reference_inputs` arrived with v2, and the governance and treasury fields with v3. A contract sees the shape of the version it was compiled against, the `v3` recorded in its blueprint, from **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**, and it keeps that view for as long as it exists. A later upgrade cannot change what an already deployed contract is shown. +The list also grows. Each version of the on-chain language has added fields: `reference_inputs` arrived with v2, and the governance and treasury fields with v3. A contract sees the shape of the version it was compiled against (the `v3` recorded in its blueprint, from **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**), and it keeps that view for as long as it exists. A later upgrade cannot change what an already deployed contract is shown. ::: ## One transaction, every script From f63919ff2d50bdf3985b562ce0d348e7c2c0a0c0 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:10:09 -0300 Subject: [PATCH 57/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index c704bc62ef..bf54b84711 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -39,7 +39,7 @@ The names above are spelled the way this track's examples spell them, and anothe The list also grows. Each version of the on-chain language has added fields: `reference_inputs` arrived with v2, and the governance and treasury fields with v3. A contract sees the shape of the version it was compiled against (the `v3` recorded in its blueprint, from **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**), and it keeps that view for as long as it exists. A later upgrade cannot change what an already deployed contract is shown. ::: -## One transaction, every script +## One transaction context for all validators A transaction can trigger more than one script: two contracts being spent at once, or a mint and a spend under the same hash. **They are all handed the same transaction.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting, while the facts they judge are identical. From 674a6caebe55d703c3d4c2b5b923025d390f7833 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:10:39 -0300 Subject: [PATCH 58/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index bf54b84711..806f30035f 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -41,7 +41,7 @@ The list also grows. Each version of the on-chain language has added fields: `re ## One transaction context for all validators -A transaction can trigger more than one script: two contracts being spent at once, or a mint and a spend under the same hash. **They are all handed the same transaction.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting, while the facts they judge are identical. +A single transaction can trigger more than one script/validator: two contracts being spent at once, or a mint and a spend under the same hash. **They all receive the same transaction context.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting. That is what makes contracts work together on Cardano. They never call each other, because they do not have to: one script can require something of a transaction and rely on another script seeing the same thing. **Multi validators** builds exactly that, a mint and a spend cooperating inside one transaction. From 01ef877e114ff4d15b677d00f7367398d42b1662 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:11:34 -0300 Subject: [PATCH 59/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 806f30035f..dfd83dc6c3 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -43,7 +43,7 @@ The list also grows. Each version of the on-chain language has added fields: `re A single transaction can trigger more than one script/validator: two contracts being spent at once, or a mint and a spend under the same hash. **They all receive the same transaction context.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting. -That is what makes contracts work together on Cardano. They never call each other, because they do not have to: one script can require something of a transaction and rely on another script seeing the same thing. **Multi validators** builds exactly that, a mint and a spend cooperating inside one transaction. +That is what makes contracts work together on Cardano. They never call each other because they don't have to: one script can require something of a transaction that can only happen if another script accepts the transaction, and vice versa. No direct interaction between scripts. It also has a sharp edge. If two contracts each demand "5 ADA must go to my address", one output paying 5 ADA to the right place can satisfy both at once, which is not what either author intended. That is called double satisfaction, and the handbook's [security page](/docs/developers/curriculum/smart-contracts/security) covers it and the rest of the family. From 6f6aece8c120e318e3a90bb7e9685d286fd98e4d Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Thu, 27 Aug 2026 13:12:05 -0300 Subject: [PATCH 60/89] Update docs/developers/onboarding/lectures/intermediate/5-transaction-context.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/5-transaction-context.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index dfd83dc6c3..1cdb210535 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -45,7 +45,6 @@ A single transaction can trigger more than one script/validator: two contracts b That is what makes contracts work together on Cardano. They never call each other because they don't have to: one script can require something of a transaction that can only happen if another script accepts the transaction, and vice versa. No direct interaction between scripts. -It also has a sharp edge. If two contracts each demand "5 ADA must go to my address", one output paying 5 ADA to the right place can satisfy both at once, which is not what either author intended. That is called double satisfaction, and the handbook's [security page](/docs/developers/curriculum/smart-contracts/security) covers it and the rest of the family. ## One field is the whole vault From fa6744465a86bf370d44cb5cecd7ad1932fc3f45 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Sun, 30 Aug 2026 19:53:01 -0300 Subject: [PATCH 61/89] Improve intermediate lectures and split the vault's mint policy into its own script --- .../intermediate/1-on-chain-vs-off-chain.md | 20 +-- .../lectures/intermediate/2-tools.md | 24 ++- .../intermediate/3-what-is-a-validator.md | 32 ++-- .../intermediate/4-datum-and-redeemer.md | 51 ++---- .../intermediate/5-transaction-context.md | 48 ++---- .../lectures/intermediate/6-testing.md | 102 ++++++------ .../lectures/intermediate/7-parameters.md | 44 +++--- .../intermediate/8-validator-purposes.md | 100 ++++++------ .../intermediate/9-frontend-integration.md | 82 +++++----- .../lectures/intermediate/introduction.md | 14 +- .../mesh/blueprints/vault.plutus.json | 40 ++--- .../vault/off-chain/mesh/src/app.tsx | 4 +- .../vault/off-chain/mesh/src/check.ts | 10 +- .../vault/off-chain/mesh/src/lib/blueprint.ts | 10 +- .../vault/off-chain/mesh/src/lib/mint.ts | 13 +- .../vault/off-chain/mesh/src/lib/recover.ts | 19 +-- .../vault/off-chain/mesh/src/main.tsx | 10 +- .../vault/on-chain/aiken/aiken.lock | 11 -- .../vault/on-chain/aiken/aiken.toml | 5 - .../vault/on-chain/aiken/plutus.json | 40 ++--- .../vault/on-chain/aiken/validators/vault.ak | 145 ++++++++++-------- .../on-chain/aiken/validators/vault_simple.ak | 87 +++++++---- 22 files changed, 456 insertions(+), 455 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md index 5240d30bd4..b39053342a 100644 --- a/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md +++ b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md @@ -20,14 +20,14 @@ An app built on a blockchain is called a **dApp**, short for decentralized appli You built the first four in Beginner, and **[a transaction, step by step](/docs/developers/onboarding/lectures/beginner/providers-and-explorers#a-transaction-step-by-step)** shows them working together. The smart contract is what this track adds. -One idea has to be clear before any code, because the rest of the track is built on it. Two of those pieces do completely different jobs: +Two of those pieces do completely different jobs: - **Off-chain** is the code that runs **in your browser or on a server** (your app, plus an off-chain SDK). It reads the chain, **builds transactions**, and asks the wallet to sign them. This is the same work you did for the [send](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) and [mint](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) transactions in Beginner. It **prepares**. - **On-chain** is the **smart contract (logic) and data that lives on the blockchain**. A Cardano smart contract is code that runs on the blockchain and checks whether the transaction is allowed. It either **approves or rejects** the transaction. It **enforces**. The apps you built [in Beginner](/docs/developers/onboarding/lectures/beginner/introduction) had only off-chain code. -Think of applying for a permit to build something. Your app is the person applying: it decides what it wants to build, fills in every field, and hands the form in. The contract is the officer who reads the form and either approves it or rejects it. The person can ask for anything, and the officer decides what is allowed. Notice what the officer never does. They do not decide what to build, and they do not build it themselves. They only decide yes or no. +Think of applying for a permit to build something. Your app is the person applying: it decides what it wants to build, fills in every field, and hands the form in. The contract is the officer who reads the form and either approves it or rejects it. The person can ask for anything, and the officer decides what is allowed. ```mermaid flowchart LR @@ -44,7 +44,7 @@ flowchart LR Wallet -->|submits| Chain ``` -Read it from left to right. Everything in the left box is your side of the line. It is work your code does before anything is final. As soon as the transaction is sent, control passes to the chain, and the validator makes the final decision. Notice that no arrow comes back. The contract cannot ask your app for more information, and your app cannot change the answer. +As soon as the transaction is sent, control passes to the chain, and the validator makes the final decision. The contract cannot ask your app for more information, and your app cannot change the answer. ## Who does what @@ -59,7 +59,7 @@ Split any Cardano app along that line and it becomes much easier to understand: | Collect the wallet's signature | See which signatures are on the transaction | | Submit | Answer **yes** or **no** | -Almost every line is on the left. The next section explains why. +Almost every line is on the left. ## Why the split exists @@ -78,11 +78,11 @@ That is a promise about the **contract's answer**, not about the transaction get There is a practical reason for the split as well. Everything on-chain is stored by every node and re-checked forever, so moving the transaction building there too would grow the chain faster than most people could afford to keep up with, and a chain only a few can verify is not decentralized. -## Two things that surprise newcomers +## Where the contract runs, and what it can do -**The contract does not run on your computer.** You write it, compile it, and read it in your editor, so it is easy to think of it as part of your app. It is not. Your app carries the compiled contract **inside the transaction**, and the **network** runs it when that transaction is checked. The answer is the same for everyone, forever. +**The contract does not run on your computer.** You write it, compile it, and read it in your editor, so it is easy to think of it as part of your app. Your app carries the compiled contract **inside the transaction**, and the **network** runs it when that transaction is checked. The answer is the same for everyone, forever. -**The contract cannot _do_ anything.** It never sends funds, never updates a balance, and never changes data on its own. Every movement of value in this track is done by a **transaction your off-chain code built**. All the contract ever adds is a yes or a no. All the action is off-chain, and all the enforcement is on-chain. Remember that sentence, because the rest of this track repeats it in different forms. +**The contract cannot _do_ anything.** Every movement of value in this track is done by a **transaction your off-chain code built**. All the contract ever adds is a yes or a no. ## Try it @@ -107,13 +107,13 @@ cardano-vault/ └── off-chain/ <- the app. Runs on your machine and builds transactions. Enforces nothing. ``` -Both are empty. The next lecture puts a contract project in `on-chain/` and leaves you working inside it. `off-chain/` stays empty until **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, which is the one place in the track you change folder again. +The next lecture puts a contract project in `on-chain/` and leaves you working inside it. `off-chain/` stays empty until **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, which is the one place in the track you change folder again. Keep the name or pick your own, and read `cardano-vault/` as "wherever you put it". -Whether a file is on-chain or off-chain is always worth knowing. Code in `off-chain/` can be wrong, or replaced. The network does not care, because it checks every transaction against what is in `on-chain/`. Code in `on-chain/` is the part the network enforces. +Code in `off-chain/` can be wrong, or replaced. The network does not care, because it checks every transaction against what is in `on-chain/`. -For the vault you are about to build, the split runs like this. Off-chain builds a transaction that sends ADA to the contract's address, which locks it. Later, off-chain builds a second transaction that tries to spend it back, so on-chain, the validator runs and answers yes or no, and only a "yes" allows the spend. Every contract you write follows this shape. +For the vault you are about to build, the split runs like this. Off-chain builds a transaction that sends ADA to the contract's address, which locks it. Later, off-chain builds a second transaction that tries to spend it back, so on-chain, the validator runs and answers yes or no, and only a "yes" allows the spend. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. diff --git a/docs/developers/onboarding/lectures/intermediate/2-tools.md b/docs/developers/onboarding/lectures/intermediate/2-tools.md index f2322c604d..5fa46ec4eb 100644 --- a/docs/developers/onboarding/lectures/intermediate/2-tools.md +++ b/docs/developers/onboarding/lectures/intermediate/2-tools.md @@ -9,23 +9,21 @@ import TabItem from "@theme/TabItem"; # Set up your tools -Two halves, two sets of tools. The on-chain half needs a **compiler and supporting tooling**, because a contract has to become a program the network can run. The off-chain half needs a **library, a provider, and a way to interact with a wallet**, because your app has to read the chain, build transactions, get them signed, and submit them. +The on-chain half needs a **compiler and supporting tooling**, because a contract has to become a program the network can run. The off-chain half needs a **library, a provider, and a way to interact with a wallet**, because your app has to read the chain, build transactions, get them signed, and submit them. -**You only need the first set now.** The next six lectures are the contract and nothing else: you write it, compile it, test it, and finish it. The app comes afterwards, once the contract is done, so the focus stays on the contract's own concepts. The off-chain half then arrives all at once in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. +**You only need the first set now.** The next six lectures are the contract and nothing else: you write it, compile it and test it. The off-chain half then arrives all at once in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. ## The on-chain toolchain -You do not write the code the network runs. You write it in a high-level language and **compile** it. Several languages do this for Cardano: +You write the contract in a high-level language and **compile** it into the code the network runs. Several languages do this for Cardano: - **[Aiken](https://aiken-lang.org/)** is a language made from scratch to write Cardano contracts. It is a small language with a fast compiler and built-in tests, and it is the easiest place to start. - **[Scalus](https://scalus.org/)** lets teams who already use Scala write contracts in the language they know. - Others exist for Haskell, Python and TypeScript teams. The [handbook compares them](/docs/developers/curriculum/smart-contracts/choose-a-language), and **[Builder Tools](/tools)** lists them all. -Different languages, **same output**. They all compile to the same low-level program, and they all describe it in the same file format, the **CIP-57 blueprint**. Your off-chain code reads that file and never needs to know which language made it. So the choice matters for your team, not for the chain. +They all compile to the same low-level program, and they all describe it in the same file format, the **CIP-57 blueprint**. Your off-chain code reads that file and never needs to know which language made it. -## The off-chain toolchain, so you know what is coming - -Nothing to install here. This is the shape of the off-chain half, so that the choices you make now make sense. Three pieces, each with one job. +## The off-chain toolchain **The SDK** builds Cardano transactions for you. Without one, every transaction would cost you a lot of time and a lot of code. There are SDKs for JavaScript, Python, Haskell, Java, Go and more, and **[Builder Tools](/tools)** lists them all. @@ -36,7 +34,7 @@ Nothing in these lectures depends on the one you pick: the contract is the same, - **You read UTxOs that are not yours.** Locked funds sit at a contract's address. Your wallet knows nothing about them, so the provider is the only way to find them. - **A script transaction has to declare its cost.** Running a validator uses CPU and memory, and the transaction carries the budget it expects to use, written next to the redeemer. You also pay for that budget in the fee. So something has to run the contract first, against your unsigned transaction, to find the real number. Your SDK can do that on your machine, or hand the job to a provider that offers it. Either way the answer arrives before you send anything, which is why a contract that says no usually fails in your app rather than on the chain. -You made a free **[Blockfrost](https://blockfrost.io/)** Preview key during setup. That is the provider. Others are listed in **[Builder Tools](/tools)**, and some of them you can run yourself. +You made a free **[Blockfrost](https://blockfrost.io/)** Preview key during setup. Others are listed in **[Builder Tools](/tools)**, and some of them you can run yourself. **The wallet** holds the keys and signs. Your app never sees a private key: it hands the finished transaction to the wallet, the wallet asks the user, and the user approves. Here that is **[Lace](https://www.lace.io/)** on Preview. @@ -44,7 +42,7 @@ Keep your Blockfrost key and your Lace wallet where they are. Neither is touched ## Try it -**Set up the contract project.** No contract in it yet, **[the next lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** writes that. This one gets the project compiling. +**Set up the contract project.** No contract in it yet, **[the next lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** writes that. @@ -73,9 +71,7 @@ Check that the project works: aiken check ``` -It compiles and reports `"total": 0` tests, because the project is empty. That is the answer you want here. - -Your contracts go in `validators/`. +It compiles and reports `0` tests, because the project is empty. @@ -85,7 +81,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -Your workspace now has something in the on-chain folder: +Your workspace: ``` cardano-vault/ @@ -96,8 +92,6 @@ cardano-vault/ └── off-chain/ <- still empty, filled in lecture 9 ``` -You are ready to write your first validator. Keep going in the next lecture. - Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper diff --git a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md index 9c9239a5c6..8f9317128c 100644 --- a/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -9,9 +9,9 @@ import TabItem from "@theme/TabItem"; # What a validator is -The simplest smart contract on Cardano is a **validator**: a small function the network runs when a transaction tries to do something that validator guards. Spending a **locked** UTxO is the most common case, and the one this lecture uses. Minting is another, and your vault gains that purpose in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the action is allowed. If it says no, the whole transaction is rejected and nothing it was trying to do takes place. +The simplest smart contract on Cardano is a **validator**: a small function the network runs when a transaction tries to do something that validator guards. Spending a **locked** UTxO is the most common case, and the one this lecture uses. Minting is another, and your vault gains that purpose in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. It looks at the transaction and returns exactly one thing, **yes (true)** or **no (false)**. If it says yes, the action is allowed. If it says no, the whole transaction is rejected. -Here is the part that surprises people: **a validator never moves funds.** Think of it as a **guard at a door** rather than a program that holds money and pays it out. A guard does not carry anything in or out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. It works the same way here. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. +**A validator never moves funds.** Think of it as a **guard at a door** rather than a program that holds money and pays it out. They stand at one door, look at each person who arrives, and say "yes, you may pass" or "no". Everything that happens on the other side of the door is done by somebody else. The value is moved by the **transaction**, which your off-chain code built, and the validator only approves it. So a validator is defined by what it **refuses**. A guard who lets everyone through is not guarding anything. Writing a contract means choosing the cases where you say no. @@ -21,13 +21,11 @@ So a validator is defined by what it **refuses**. A guard who lets everyone thro What ties them together is a single rule: **every validator the transaction triggers has to say yes.** If a single validator rejects it, the whole transaction is rejected. That is how contracts cooperate on Cardano, by each making its own demand of the same transaction. -The next few examples use a single validator, but we'll cover more complex contracts later. - ## Where the locked funds live Remember from Beginner that a [UTxO](/docs/developers/onboarding/lectures/beginner/utxos-and-transactions) (a "sealed bag") always sits at an **[address](/docs/developers/onboarding/lectures/beginner/wallets-keys-addresses)**. Most of the addresses you have used belong to a person. These are **key addresses**, and whoever holds the matching private key can spend what is there. -You have already met the other kind. When Bob locked 5 ADA behind a native script in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata), the funds went to a **script address**, controlled by **a set of rules** instead of a person. A validator uses the same kind of address. The only difference is how complex the rules can be. Validators allow for arbitrarily complex logic (as long as you're within the transaction's budget). +You met the other kind when Bob locked 5 ADA behind a native script in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata): the funds went to a **script address**, controlled by **a set of rules** instead of a person. A validator uses the same kind of address. The only difference is how complex the rules can be. Validators allow for arbitrarily complex logic (as long as you're within the transaction's budget). ```mermaid flowchart TB @@ -42,15 +40,15 @@ flowchart TB K ~~~ S ``` -Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. The only difference is what it takes to open them. A script address has no key, no recovery phrase, and nobody who can give permission. Even the person who wrote the contract has to satisfy the rule like everyone else. +Both hold ordinary UTxOs, with the same ADA and tokens, on the same explorer page. A script address has no key. Even the person who wrote the contract has to satisfy the rule like everyone else. -Where does that address come from? From the validator itself, using the same hashing you saw there. You hash the compiled contract, and use that fingerprint to derive the address. Change one character of the contract, and you get a completely different address that guards completely different funds. You will do exactly this in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. +That address comes from the validator itself, using the same hashing you saw there. You hash the compiled contract, and use that fingerprint to derive the address. Change one character of the contract, and you get a completely different address that guards completely different funds. You will do exactly this in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. ## Locking is just a payment -A very important detail: **the validator does not run when you lock funds (create a UTxO in its address).** +**The validator does not run when you lock funds (create a UTxO in its address).** -Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. The contract does not run at all. +Sending ADA to a script address is an **ordinary payment**. Your wallet does not know or care that the recipient is a script. The UTxO simply arrives and sits there, with a note attached to it. That note is the **datum**, and it has [a lecture of its own](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer) next. It runs only when someone tries to **spend** that UTxO. At that moment the network takes the validator, gives it the transaction, and asks its one question. @@ -62,7 +60,7 @@ flowchart LR V -->|no| No["transaction rejected,
the UTxO stays put"] ``` -So a validator only checks funds on the way **out**, never on the way in. That's why it's called a "spending validator" (we'll explain more in the [purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes) lecture). Anyone can send funds in, even by mistake, and nothing checks them. Only taking them out is guarded. This matters more than it first appears, since it affects how you write your logic. You lock first, and all the interesting logic happens at the spend. +So a validator only checks funds on the way **out**, never on the way in. That's why it's called a "spending validator" (we'll explain more in the [purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes) lecture). Anyone can send funds in, even by mistake, and nothing checks them. ## What the validator sees @@ -76,22 +74,22 @@ validator(datum, redeemer, context) -> True | False - **redeemer** what the spender provides when unlocking, - **context** the whole transaction around it. -Depending on the language you choose to write your validators in, you can see more or fewer arguments. That is just the language trying to be convenient for you. At the end of the day, everything is inside the transaction context. +Depending on the language you choose to write your validators in, you can see more or fewer arguments. -The validator cannot access anything else. No network access, no clock, no storage, and nothing about the world beyond what it is given. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next lectures cover all three inputs in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds a way to hardcode values directly into the validator. +The validator cannot access anything else. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** explained why. The next lectures cover all three inputs in detail, and then [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters) adds a way to hardcode values directly into the validator. :::warning A validator is only as good as what it refuses -Think about the two simplest validators possible, and you will see the full range you are working in: +Think about the two simplest validators possible: - **Always true** returns `True` no matter what, so **anyone** can spend the funds, for any reason, at any time. - **Always false** returns `False` no matter what, so **nobody** can ever spend them. The funds are **permanently unspendable**: not by you, not by the person who locked them, not by anyone, ever. -Real people have shipped both of these by mistake. A contract that always passes gives the funds away to whoever asks first. A contract that always fails means nobody can ever move them ([locked value](/docs/developers/curriculum/smart-contracts/security#locked-value) in the handbook). Neither mistake can be undone. Real validators sit between these two and say yes only when specific conditions are met. This is also why you test the vault in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**, and why those tests are mostly about what it refuses. +Real people have shipped both of these by mistake, and neither can be undone ([locked value](/docs/developers/curriculum/smart-contracts/security#locked-value) in the handbook). Real validators sit between these two and say yes only when specific conditions are met. This is also why you test the vault in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**, and why those tests are mostly about what it refuses. ::: ## Try it -**Write both extremes and compile them.** You write one file and change one word in it, so you see the pair from the box above: the validator that always says yes, and the one that always says no. +**Write both extremes and compile them.** You write one file and change one word in it, so you see the pair from the box above. @@ -112,9 +110,9 @@ validator vault { } ``` -`validator vault` names the script. `spend` is the handler that runs when someone tries to spend a locked UTxO. The underscore in front of each name means "given, but not used here", so this contract ignores everything it is handed. +`validator vault` names the script. `spend` is a **handler**: a block inside the validator that runs for one kind of action. This one runs when someone tries to spend a locked UTxO. A validator can hold several, one per action it guards, and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is where you add a second. The underscore in front of each argument name means "given, but not used here", so this contract ignores everything it is handed. -Four arguments, three ideas. `_datum` and `_redeemer` are the first two from the list above. `_own_ref` points at the UTxO being spent, and `_self` is the whole transaction context. What is inside it is the subject of **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. +`_datum` and `_redeemer` are the first two from the list above. `_own_ref` points at the UTxO being spent, and `_self` is the whole transaction context. What is inside it is the subject of **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. The body is the entire rule: `True`, yes to everybody. diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index 0635b36084..d38615fc63 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -12,16 +12,14 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme # Datum & redeemer -[Last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator) said a validator is a function of **datum**, **redeemer**, and **context**. The validator you wrote ignores all three. This lecture is about the first two, which are how you give information to a contract. They are also the part newcomers most often confuse, so they are worth explaining carefully. +[Last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator) said a validator is a function of **datum**, **redeemer**, and **context**. The validator you wrote ignores all three. This lecture is about the first two, which are how you give information to a contract. -- The **datum** is information attached to the UTxO when you **lock it**. You can put anything in there. Addresses, numbers, information, anything! The important part is that it is fixed the moment the funds are locked and never changes. +- The **datum** is information attached to the UTxO when you **lock it**. You can put anything in there. It is fixed the moment the funds are locked and never changes. - The **redeemer** is what the **spender provides** when they try to unlock. It is their _choice_ for this attempt (transaction), and they supply it fresh in the spending transaction. -- The **context** is the rest of the transaction: its inputs, outputs, signatures, the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano), and more. The validator can read all of it. There is enough info to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own, so this one is about the first two. +- The **context** is the rest of the transaction: its inputs, outputs, signatures, the validity window from [Time on Cardano](/docs/developers/onboarding/lectures/beginner/time-on-cardano), and more. The validator can read all of it. There is enough info to fill [the next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) on its own. Imagine you leave a bag with someone for safe keeping. That bag is a **UTxO**. When you hand it over, they attach a note that says "give this back only to the person holding ticket 42". That note stays with the bag, and it is the **datum**. Later somebody arrives and says what they want: "I am here to collect the bag." That request is the **redeemer**. The note alone decides nothing, and the request alone decides nothing. The decision needs both together, plus the situation they arrive in, which is the context. -So: **datum is what was set at lock time, and redeemer is what the spender says now.** Add the context, and the validator considers all three and returns yes or no. - ```mermaid sequenceDiagram participant You as Your app @@ -49,7 +47,7 @@ second against the first, in the situation the context describes. ## A tiny example -Our example contract is a **vault**. It locks some funds so that only their owner can take them back. The datum names the **owner**, and the redeemer is the **action** the spender is taking. Here there is only one action, `Unlock`. Here are those two types on-chain: +Our example contract is a **vault**. It locks some funds so that only their owner can take them back. The datum names the **owner**, and the redeemer is the **action** the spender is taking. Here are those two types on-chain: @@ -58,7 +56,7 @@ Our example contract is a **vault**. It locks some funds so that only their owne {extractRegion(VaultSimple, "types")}
-Read it as two shapes being declared: +Two shapes: - `VaultDatum` has a single field, `owner`, of type `VerificationKeyHash`. That is a **public key hash**, the short fingerprint of a public key. Native scripts used the same thing to name a signer back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). - `VaultAction` has a single choice, `Unlock`. A larger contract would list several, such as `Unlock`, `Cancel`, and `Extend`; the validator would check which one the spender chose and adjust its checks accordingly. @@ -71,28 +69,10 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -## How those shapes are stored - -Those two declarations look like ordinary types, but they describe **bytes on the chain**, and it is worth knowing what those bytes are before anything has to produce them. - -On-chain data is stored as a **numbered constructor plus a list of fields**. The number answers "which choice of the type is this?" and the list answers "what does it hold?". The number is assigned by position: the first choice declared is 0, the next is 1, and so on. Neither of our types offers a choice yet, since `VaultDatum` has one shape and `VaultAction` has one action, so both are constructor **0**: `VaultDatum { owner }` is constructor 0 carrying one field, and `Unlock` is constructor 0 carrying nothing. - -The number only starts to matter once a type offers a real choice. Had `VaultAction` listed the three actions mentioned above, they would be numbered in the order they are declared: - -```aiken -pub type VaultAction { - Unlock // constructor 0 - Cancel // constructor 1 - Extend // constructor 2 -} -``` - -That numbering is the contract's half of an agreement. Something has to build the same bytes from the other side, and nothing checks that the two agree: send constructor 1 when you meant `Unlock` and the validator reads `Cancel`, and acts on it. Writing that other half is what **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does, once the contract has stopped changing shape. You will meet a real two-choice redeemer before then, in [Parameters](/docs/developers/onboarding/lectures/intermediate/parameters), where the vault gains a second action. - :::warning A wrong datum can't be undone -The chain does not check that your datum matches what the validator expects. It stores whatever bytes you attach. If you get the shape wrong, with the wrong constructor number, the wrong number of fields, or the fields in the wrong order, the mistake is not caught at lock time, because the contract does not run when you lock. It is caught later, when the validator tries to read the datum, **fails**, and answers no. Every time, for everyone. +The chain does not check that your datum matches what the validator expects. It stores whatever bytes you attach. If you get the shape wrong, with the wrong number of fields, the fields in the wrong order, or the wrong kind of value in one of them, the mistake is not caught at lock time, because the contract does not run when you lock. It is caught later, when the validator tries to read the datum, **fails**, and answers no. Every time, for everyone. -The funds are then permanently unspendable. There is no way to undo it and nobody who can help. This is one of the common ways people lose funds on Cardano. It is why the matching above needs care, and why the vault gets a full set of tests in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**. +The funds are then permanently unspendable. There is no way to undo it and nobody who can help. This is one of the common ways people can lose funds on Cardano. It is why building the datum needs care when you write the off-chain code in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and why the vault gets a full set of tests in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)**. ::: ## Where the datum actually lives @@ -107,16 +87,16 @@ Inline is the newer of the two and the better default. The datum travels with th :::danger Everything on-chain is public The datum and the redeemer are stored **openly** on the blockchain, and anyone can read both. So a contract can **never keep a secret**. Do not put a password, a private number, or a "guess this number" puzzle in a datum, because everyone can see it. -This is why our vault's datum holds only the owner's **public** key hash, and the real lock is a **signature**. Data can be read, but a signature cannot be faked. Contracts protect funds with things a spender cannot fake: **signatures, tokens, and time**. Never with hidden data. +This is why our vault's datum holds only the owner's **public** key hash, and the real lock is a **signature**. Data can be read, but a signature cannot be faked. Contracts protect funds with things a spender cannot fake: **signatures, tokens, and time**. ::: :::tip Datum for state, redeemer for action -A useful guide for the rest of this track: put the **facts that must be kept** (here, the owner) in the datum, and the **action the spender is taking** (here, `Unlock`) in the redeemer. The validator then checks the context. Our vault checks that the transaction is **signed by that owner**. +Put the **facts that must be kept** (here, the owner) in the datum, and the **action the spender is taking** (here, `Unlock`) in the redeemer. The validator then checks the context. Our vault checks that the transaction is **signed by that owner**. ::: ## Try it -**Give your vault the two shapes.** Right now it accepts anything, and it does not even know what it is being handed. +**Give your vault the two shapes.** Right now it accepts anything. @@ -125,7 +105,7 @@ Everything below runs from `on-chain/vault/`, where lecture 2 left you. Open `validators/vault.ak`, the file you wrote [last lecture](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator). It has one `validator` block in it and nothing else. -The shapes you are about to write need two things from the standard library: a type for the owner's key hash, and the types the handler is handed. Add both as the **first lines of the file**: +The shapes you are about to write need two things from the **[standard library](https://github.com/aiken-lang/stdlib)**: a type for the owner's key hash, and the types the handler is handed. The stdlib carries most of what a validator needs, from the ledger types you are importing here to helpers for lists, bytes and time intervals. Add both as the **first lines of the file**: {extractRegion(VaultSimple, "datum-imports")} @@ -163,10 +143,10 @@ Save it, and: aiken check ``` -Everything should be working, but **what changed?**: +Everything should be working. **What changed:** -- `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. However, if there is a datum, it'll be of type `ValutDatum`. -- `expect Some(VaultDatum { owner }) = datum` means "there must be a datum, it must be a `VaultDatum`, and I want its `owner`". If any of that is untrue the validator fails and the spend is refused. This is the line the warning above describes: it is where a mismatched datum gets caught, long after it was attached. +- `datum: Option` uses `Option` because an output at a script address **might have no datum at all**. Anyone can send funds there without one. The contract has to handle that case rather than assume. +- `expect Some(VaultDatum { owner }) = datum` means "there must be a datum, it must be a `VaultDatum`, and I want its `owner`". If any of that is untrue, the validator fails and refuses the spend. The `expect` keyword is special: it lets us recursively pattern-match the shape of a type and bind its inner values to names (like we did with `owner`), and if one thing is wrong, it automatically rejects the transaction. You can learn more about how this works [here](https://aiken-lang.org/language-tour/control-flow#expect). The contract still returns `True`, so it still gives the funds to anybody. But it now insists on being handed a note it can read, and it knows the owner. The [next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) is where that owner starts deciding things. @@ -184,7 +164,6 @@ Open `plutus.json` and find the `hash` under the `validators` list. Compare it w If it matches, your vault compiles to exactly the same script as ours, byte for byte, which means the same address. If it does not, something in the file differs from the code above, so go back over the imports, the two types and the handler. The hash will change again in the [next lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context), because the contract does. - @@ -193,7 +172,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -**And the redeemer?** You cannot watch it decide anything yet. `VaultAction` offers only one choice, and it contains no data. So every spender sends the same thing, and it changes nothing. A redeemer only starts doing real work once there is more than one action to pick from or when it provides information inside, which will happen in **[a future lecture](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. +**And the redeemer?** You cannot watch it decide anything yet. `VaultAction` offers only one choice, and it contains no data. A redeemer only starts doing real work once there is more than one action to pick from or when it provides information inside, which will happen in **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** when the vault gains a second way to be opened. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index 1cdb210535..cf28ad22d0 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -16,13 +16,13 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme The **context** is the **transaction itself**. When someone tries to spend your locked funds, the network hands your contract the entire transaction that is trying to do it, and lets the contract look at any part of it before answering. -So the datum and the redeemer are two small values you supply. The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent and all their properties, which tokens are being minted and burned, who signed the transaction, the time window the transaction declared, etc. +The context is **everything else the contract can see about the transaction it is judging**: which UTxOs are being spent and all their properties, which tokens are being minted and burned, who signed the transaction, the time window the transaction declared, etc. A validator that only compares the datum with the redeemer protects nothing. Both are data, and data cannot show who signed, what moved, or when it happened. Only the transaction shows that, which is why nearly every check you write is a question about the transaction, measured against what the datum says. Your vault is about to ask exactly one: is the owner named in the datum among the keys that signed? ## What is inside -The context holds one transaction, described in full. Here is everything in it, grouped so it is easier to hold in your head than a flat list: +The context holds one transaction, described in full. Here is everything in it: | Group | Fields | What it tells you | |---|---|---| @@ -31,10 +31,8 @@ The context holds one transaction, described in full. Here is everything in it, | **Who and when** | `extra_signatories`, `validity_range` | the keys the transaction requires a signature from, and the time window it declared | | **The rest** | `certificates`, `withdrawals`, `redeemers`, `datums`, `id`, and the governance and treasury fields | staking, voting, the transaction's own id, and the datums and redeemers it carries | -Depending on what your contract wants to check, you'll choose what to look at. - :::note These names come from the ledger, not from a language -The names above are spelled the way this track's examples spell them, and another language will write some of them a little differently. What the list holds is decided by **Cardano**, not by the tool you write your contract in, so learn it once and it carries over. +The names above are spelled the way this track's examples spell them, and another language will write some of them a little differently. What the list holds is decided by **Cardano**, not by the tool you write your contract in. The list also grows. Each version of the on-chain language has added fields: `reference_inputs` arrived with v2, and the governance and treasury fields with v3. A contract sees the shape of the version it was compiled against (the `v3` recorded in its blueprint, from **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)**), and it keeps that view for as long as it exists. A later upgrade cannot change what an already deployed contract is shown. ::: @@ -43,27 +41,26 @@ The list also grows. Each version of the on-chain language has added fields: `re A single transaction can trigger more than one script/validator: two contracts being spent at once, or a mint and a spend under the same hash. **They all receive the same transaction context.** Only the purpose-specific part differs, so each one knows which UTxO it is guarding, or which policy is minting. -That is what makes contracts work together on Cardano. They never call each other because they don't have to: one script can require something of a transaction that can only happen if another script accepts the transaction, and vice versa. No direct interaction between scripts. - +That is what makes contracts work together on Cardano. They never call each other because they don't have to: one script can require something of a transaction that can only happen if another script accepts the transaction, and vice versa. ## One field is the whole vault -Here is the finished vault, the contract you are two steps away from. It is one line of rule, and that line is a question about the transaction: +These are the spend validator's rules, which decide whether the transaction is accepted: - {extractRegion(VaultSimple, "validator")} + {extractRegion(VaultSimple, "validator", "traces")} -`self` **is** the context. It is the transaction, handed straight to the handler, and `self.extra_signatories` is one of the fields from the list above: the keys the transaction requires a signature from, which is a list the transaction **declares**, not the signatures themselves. The whole rule is one question about it: _is the owner named in the datum among the signers?_ `list.has` asks whether something is in a list, and that is the entire contract. +`self` **is** the context. It is the transaction, handed straight to the handler, and `self.extra_signatories` is the field that contains the keys that this transaction requires a signature. The transaction lists those keys itself, and you can trust the list, because the node verifies the matching signatures in phase 1, before any validator runs. The validator check is one question about that list: _is the owner named in the datum among the signers?_ `list.has` asks whether something is in a list. :::note Coming from Ethereum? There is no `msg.sender` here, and nothing plays that role. A transaction has no single caller, because it can carry many signatures at once. So you never ask "who called me", you ask whether the key you care about is among the signers. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers the rest of that shift. ::: -Each argument the handler receives comes from somewhere definite. The **datum** was attached to the UTxO when it was locked. The **redeemer** comes from the transaction trying to spend it. `_own_ref` says which UTxO that is, and `self` is the transaction itself. +`_own_ref` says which UTxO is being spent, and `self` is the transaction itself. A `mint` handler is handed a different set, because nothing is being unlocked: no datum, no `_own_ref`, and the policy id instead. What you are given depends on the **purpose**, which has its own lecture in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. @@ -75,19 +72,9 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -The signature alone tells you what this contract ignores. A leading underscore means "handed over, never used", so `_redeemer` and `_own_ref` are ignored: the vault reads its datum, reads the transaction, and looks at nothing else. That is also why the last lecture could not show you a redeemer deciding anything: `VaultAction` offers a single action, so there is nothing to branch on, and the vault does not even look. - -Now imagine `self` had an underscore too. The vault would be left with the datum and the redeemer and nothing else. The datum is public, and the redeemer is chosen by whoever is spending, so every rule you could still write comes down to asking the spender to supply a value they can already read. Signatures, tokens and time all live in the context, and a contract that never looks at the transaction cannot protect anything at all. +The signature alone tells you what this contract ignores. `_redeemer` and `_own_ref` are handed over and never used: the vault reads its datum, reads the transaction, and looks at nothing else. -Five of the context's fields carry the rest of this track, and the vault has just shown you the first. The contract shape stays the same each time; only the question changes: - -- `extra_signatories`: only the owner may unlock, in the Try it below. -- `mint`: a contract that controls its own token, in [Validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes) and then the gift card in Multi validators. -- `validity_range`: funds that cannot move before a date, in Handling time. -- `inputs` and `outputs`: data that is updated instead of released, in Modifying state. -- `reference_inputs`: reading another contract's data without spending it, in Reference inputs. - -Learning contracts is mostly learning which field answers which question. +A rule built from the datum and the redeemer alone can only ask the spender to repeat what the datum already says: your vault could demand a redeemer equal to its `owner`, and every passer-by who read the UTxO could supply it. Signatures, tokens and time live in the context, which is where a contract's protection has to come from. ## What is not in it @@ -97,25 +84,24 @@ The context is generous, but it stops at the edge of one transaction. A contract - **other addresses**, or what anyone's balance is. - **the past**: no earlier transaction, and no history of this contract. - **the rest of the block**: other transactions being confirmed at the same moment are invisible. -- **the metadata**. This one surprises people, because you attached metadata to a transaction back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It is stored on the chain and anyone can read it, but scripts are not shown it. So a contract can never enforce a rule about metadata. - +- **the metadata**. You attached metadata to a transaction back in [Native scripts & metadata](/docs/developers/onboarding/lectures/beginner/native-scripts-and-metadata). It is stored on the chain and anyone can read it, but scripts are not shown it. So a contract can never enforce a rule about metadata. :::tip The transaction is the whole world -A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums and values, the UTxOs it references, its outputs, its signatures, its window, etc. No API to call, no database to query, not even the block it sits in. What isn't in the transaction doesn't exist, as far as the contract is concerned. +A validator runs **inside** a single transaction, and that transaction is everything it can see: its inputs and their datums and values, the UTxOs it references, its outputs, its signatures, its window, etc. -So a contract never gathers facts, it only judges the ones already in front of it, and **whoever builds the transaction has to put them there**. That is what the datum, the redeemer and the reference inputs are for. The question is never "how does the contract fetch this", it is "who puts it in, and why should the contract believe them". **Modifying state** builds an oracle, which is that question answered. +A contract judges the facts already in front of it, and **whoever builds the transaction has to put them there**. That is what the datum, the redeemer and the reference inputs are for. The question is never "how does the contract fetch this", it is "who puts it in, and why should the contract believe them". **Modifying state** builds an oracle, which is that question answered. ::: ## Try it -**Write the rule, then prove it.** Your vault knows who the owner is, and still says yes to everybody. +**Write the rule.** Your vault knows who the owner is, and still says yes to everybody. Everything below runs from `on-chain/vault/`, where lecture 2 left you. -What we check: **allow the spend only if the owner named in the datum is among the keys the transaction requires a signature from.** You have every piece. `self` is the transaction, `self.extra_signatories` is that list, `owner` came out of the datum last lecture, and `list.has` answers whether something is in a list. +What we check: **allow the spend only if the owner named in the datum is among the keys the transaction requires a signature from.** `owner` came out of the datum last lecture, and `list.has` answers whether something is in a list. In `validators/vault.ak`, make three changes: @@ -131,7 +117,7 @@ In `validators/vault.ak`, make three changes: aiken check ``` -Green, and you have written a working validator: a real rule, guarding a real address, refusing everyone who is not the owner. +Green, and you have written a working validator. **Check you wrote the same contract.** Build it and compare the hash, as you did last lecture: @@ -145,7 +131,7 @@ ec431d8627829d7e21119161d909e8a9a15d648a67bff82ccafc3570 If the `hash` in `plutus.json` matches, your vault is ours byte for byte. Notice it is not the hash you compared in **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. One line of rule changed the script, so it changed its identity and its address, exactly as **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** said it would. -At least, that is what it is supposed to do. `aiken check` compiled it and said nothing about whether the rule is right, because a compiler never does. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** is next, and it is where you find out. +At least, that is what it is supposed to do. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** is next, and it is where you find out. diff --git a/docs/developers/onboarding/lectures/intermediate/6-testing.md b/docs/developers/onboarding/lectures/intermediate/6-testing.md index 7eeeacc9db..2db6cb52e0 100644 --- a/docs/developers/onboarding/lectures/intermediate/6-testing.md +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -12,37 +12,35 @@ import VaultSimple from "!!raw-loader!@site/examples/onboarding/lectures/interme # Testing -You can't update a validator. Once funds sit behind it, a mistake means lost or given-away value, and no patch can take it back. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** puts it plainly: a validator that always says yes gives the funds away, and one that always says no means nobody can ever move them. +You can't update a validator. Once funds sit behind it, a mistake means lost or given-away value, and no patch can take it back. **[What a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** shows the two ways that goes wrong. -So the question is not whether to test. It is how to be sure **before** anything real is at risk. +[In the last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context), you wrote a real validator, and every check you have run so far has only **compiled** it. The compiler proves the contract is valid code in the language you chose. It cannot tell you whether the checks you wrote are the logic you meant. Your vault would compile just as happily with its one rule replaced by an unconditional yes. -[In the last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context), you wrote a real validator, and every `aiken check` you have run so far has only **compiled** it. The compiler proves the contract is valid Aiken. It cannot tell you whether the checks you wrote are the logic you meant. Your vault would compile just as happily with `list.has` replaced by `True`. That is what we explore in this lecture. - -Here's an overview of the types of verifications we could do to check if our contract behaves as we expect, ordered from simples/less accurate to more complex/more accurate: +The ways to check that a contract behaves as you expect, from cheapest to most accurate: ```mermaid flowchart LR - U["unit tests
one case you thought of"] --> P["property tests
one rule, a hundred inputs"] --> S["scenario tests
the whole transaction,
never submitted"] + A["Unit testing
one case you thought of"] --> B["Simple integration testing
submit a transaction on a testnet or devnet"] --> C["Property testing
define properties your contract has to comply with"] --> D["Integrated property testing
test properties with real transactions on a testnet or devnet"] --> E["Formal proofs
formally prove your contract's properties"] ``` -In this lecture, we'll cover Unit and Property testing, since they only need the contract. Integration testing requires building and submitting transactions, so we'll wait for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and Formal Methods is for when you can write protocols with your eyes closed. So, we won't cover those during the onboarding +In this lecture, we'll cover Unit and Property testing, since they only need the contract. Integration testing requires building and submitting transactions, so we'll wait for **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**, and Formal Methods is for when you can write protocols with your eyes closed. ## Unit tests -The cheapest test builds a **fake transaction**, hands it to the validator, and checks the answer. No network, no wallet, no test ADA, and it finishes in milliseconds. +The cheapest test builds a **fake transaction**, hands it to the validator, and checks the answer. No test ADA, and it finishes in milliseconds. Your vault has one real check, so it needs two tests: one for when the transaction should get through, and one for when it shouldn't. -That second one is the one that matters. Notice the balance: half of these check a **refusal**, and that is the habit worth copying for every contract in this track. **A validator is defined by what it rejects.** A validator that always said yes would pass every success test you could write, which is why a suite of nothing but success tests tells you almost nothing. +That second one is the one that matters. Half of these check a **refusal**, and that is the habit to copy for every contract in this track. A validator that always said yes would pass every success test you could write, which is why a suite of nothing but success tests tells you almost nothing. -Fake transactions are easier with a helper library. **vodka** is the one this track uses, and its `mocktail` half is the test side: it gives you a way to build a fake transaction, a fake key hash and a fake UTxO reference. You will meet its other half, `cocktail`, in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. +A transaction context has a lot of fields, and your rule reads one of them. The standard library hands you `transaction.placeholder` for exactly this: an empty transaction context, with every field at whatever counts as nothing for its type. You copy it and fill in only the field the rule looks at, so a test says which fact it is testing and stays silent about the rest. -A test lives in the same file as the contract. That is deliberate rather than untidy: the test and the rule it checks stay side by side, and `aiken build` leaves the tests out of the compiled output entirely. +A test that calls a validator has to sit in the same file as that validator: a validator's handlers are private to the module they are in, so no other file can reach them. Tests that call nothing from a validator can live anywhere in the project. `aiken build` leaves all of them out of the compiled output. -Aiken also has one keyword worth knowing before you write anything. Putting **`fail`** after a test's name means "this one is supposed to be refused", so that test passes only when the validator says **no**. +Aiken has one more keyword. Putting **`fail`** after a test's name means "this one is supposed to be refused", so that test passes only when the validator says **no**. @@ -56,26 +54,24 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o Tracing works at any of those levels: it is how you read the answer the contract gave. -A validator only ever answers yes or no. That is all the chain needs, but it is thin when a test goes red: you learn *that* the contract refused and nothing about *which* check refused it. Your vault has one check, so there is only one suspect. A contract with a dozen leaves twelve, and by the end of this track yours will have several. +A validator only ever answers yes or no. That is all the chain needs, but it is thin when a test goes red: you learn *that* the contract refused and nothing about *which* check refused it. Your vault has one check, so there is only one suspect. A contract with a dozen leaves twelve. A **trace** is a line of text the validator writes as it runs, which the test runner prints back to you afterwards. -The smallest way in is the `?` operator. Put it after any condition: - -```aiken -list.has(self.extra_signatories, owner)? -``` +The smallest way in is the `?` operator, which goes after any condition. Read it as "and tell me if this one came back False". It only reports the result, and only when that result is `False`. A check that answered `True` stays silent, so what you get back is a short list of the checks that said no. -Read that `?` as "and tell me if this one came back False". It does not change what the condition does, and it does not change what the validator decides. It only reports the result, and only when that result is `False`. +Aiken has a second way to add a trace. The `trace` keyword prints a line wherever you put it. A message on its own is enough. To print values as well, put `:` after the message, then the values, separated by commas: -That last part is what makes it useful. A check that answered `True` stays silent, so what you get back is a short list of the checks that said no, not a long report of every step that ran. + + {extractRegion(VaultSimple, "trace-example")} + -**And traces cost nothing on-chain.** That is worth checking rather than believing, because "the validator writes text now" sounds like a bigger, slower, more expensive script. It isn't: `aiken build` strips every trace back out, and the compiled script is byte for byte the one you had before. The Try it below has you prove that with your own hash rather than take it on trust. +`aiken check` prints the traces under the test. Byte values come out in a shorthand called CBOR diagnostic notation: a key hash reads as `h'…'`, and a list of them as `[_ h'…', h'…']`. Printing the signers is often enough to explain a refusal, because you can see whether the owner's hash is in the list. -Aiken has a second form, `trace @"your own message"`, for branches that do several things at once and need a label no single condition can give. Your vault's rule is one check, so `?` already says everything a message of your own could. +**Traces cost nothing on-chain.** `aiken build` strips them back out, so the compiled script is byte for byte the one you had before. @@ -87,19 +83,19 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o ## Property tests -Unit tests only check the cases you thought of. Your two name one owner, a key you picked. But the rule is not about that key. It is about **any** key: whoever the datum names must be the one who signed. +Unit tests only check the cases you thought of. Your two name one owner, a key you picked. The rule is about **any** key: whoever the datum names must be the one who signed. A **property test** states a property directly and lets the test runner find an example that breaks it. Instead of the one key you chose, it explores the space, generating cleverly crafted counterexamples hundreds or thousands of times. -If any of them fails, it does more than report it. It **reduces ("shrinks")** the counterexample to the smallest one that still breaks the property, so you get the exact edge case rather than whichever random value happened to fail first. +If any of them fails, it **reduces ("shrinks")** the counterexample to the smallest one that still breaks the property, so you get the exact edge case rather than whichever random value happened to fail first. -A property test is worth reaching for whenever a rule holds "for all" of something: every key, every amount, every moment after a deadline. You will meet that last one in **handling time**, where the vesting contract arrives with `claim_ok_at_any_time_after_the_deadline` already written. +Reach for a property test whenever a rule holds "for all" of something: every key, every amount, every moment after a deadline. You will meet that last one in **handling time**, where the vesting contract's deadline needs exactly this test. ## The level these two cannot reach -Both levels above test the validator **on its own**, and that is also their limit. They hand the contract a transaction you built by hand, in the shape you believe your app will produce. +Both levels above test the validator **on its own**. They hand the contract a transaction you built by hand, in the shape you believe your app will produce. -Many things go wrong in the gap between those two: a datum built with the wrong constructor number, a missing required signer, a redeemer that does not match. None of these are contract bugs, none of them appear in a contract test, and your vault can be perfect while your app is still unable to open it. +Many things go wrong in the gap between those two: a datum built with the wrong constructor number, a missing required signer, a redeemer that does not match. Your vault can be perfect while your app is still unable to open it. Closing that gap needs off-chain for integration testing, so it is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does once there is one: build the **real transaction** with your real off-chain code, then check the transaction against a real or simulated node. @@ -110,49 +106,63 @@ Closing that gap needs off-chain for integration testing, so it is the first thi -**Add the test library.** Your project has had no dependencies but the standard library so far. **vodka** is the first: +**Write a test that has nothing to do with the vault.** Put this at the bottom of `validators/vault.ak`: + + + {extractRegion(VaultSimple, "test-shape")} + ```bash -aiken add sidan-lab/vodka --version 0.1.23 +aiken check ``` -That writes three lines into `aiken.toml` for you, and the next `aiken check` downloads the package. `aiken add` acts on the project you are standing in, which is why lecture 2 left you inside `on-chain/vault/` rather than pointing at it from outside. +An Aiken test is a function declared with `test` where you would write `fn`. It takes no arguments, and its body has to end in a `Bool`: the test passes when that value is `True`. There is no assertion library and nothing to import. The runner executes the body the way the chain executes a validator, which is why `aiken check` prints memory and CPU numbers beside each test name. -**Add the imports.** Three things come from mocktail: `mocktail_tx()` starts an empty transaction, `required_signer_hash(True, key)` puts a key in `extra_signatories`, and `complete()` finishes it. The two `virgin_` modules invent the values to fill them with. Add these to `validators/vault.ak`: +Ending the body on a comparison (`==`, `>=`, `!=`) buys you one more thing. When the test goes red, the runner shows you both sides of the comparison and what each one came out as, instead of the single word `False`. - - {extractRegion(VaultSimple, "simple-test-imports", "simple-fuzz-import")} - +That one is scaffolding. The vault's own tests replace it. -**Then the tests**, at the bottom of the file, below the validator: +**Write the tests**, in its place, below the validator. There is nothing to install and nothing to import: `transaction.placeholder` comes from `cardano/transaction`, which the top of your file already reads `Transaction` and `OutputReference` from. {extractRegion(VaultSimple, "simple-tests")} -`mock_pub_key_hash(1)` and `mock_pub_key_hash(2)` are just two valid key hashes that are not each other. The vault never reads `dummy_ref`, so any output reference will do. Run them: +`..transaction.placeholder` is the empty transaction context, and `extra_signatories` is the one field written over it, because that is the only field the rule reads. A key hash is 28 bytes and an output reference is a transaction id with an index, so the constants are just byte strings of the right shape. The vault compares them and never inspects them, which is why ones counting up from 1 are enough. Run them: ```bash aiken check ``` -Two tests, two passes, in milliseconds. This is the cheapest place in the whole system to find out you were wrong. +Two tests, two passes, in milliseconds. + +**Read the rule the other way round.** Add one more test below the two: + + + {extractRegion(VaultSimple, "pipe")} + -**Make a refusal explain itself.** Put a `?` after the check in the `spend` handler, so it reads `list.has(self.extra_signatories, owner)?`, and run `aiken check` again. Both still pass, and nothing needed to break for that to pay off: `unlock_fails_for_a_stranger` now prints the condition you marked underneath itself, with the answer it gave, while `unlock_ok_when_the_owner_signs` stays silent because its check answered `True`. Leave the `?` there while you are still writing the contract. +`|>` takes the value on its left and hands it to the call on its right as its **first** argument, so `[owner, stranger] |> list.has(owner)` is the `list.has([owner, stranger], owner)` you already know. Your vault's rule would compile the same written as `self.extra_signatories |> list.has(owner)`. + +A single call reads much the same either way. A chain of them reads top to bottom, in the order the steps happen, and that is what the minting policy in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** uses it for. + +**Make a refusal explain itself.** Put a `?` after the check in the `spend` handler, so it reads `list.has(self.extra_signatories, owner)?`, and run `aiken check` again. They all still pass: `unlock_fails_for_a_stranger` now prints the condition you marked underneath itself, with the answer it gave, while `unlock_ok_when_the_owner_signs` stays silent because its check answered `True`. Leave the `?` there while you are still writing the contract. **Check what that cost you.** Run `aiken build` and note the `hash` in `plutus.json`. Now take the `?` out, build again, and compare. Same hash, so the same compiled script either way: the trace never reached the chain. Put the `?` back. -**Break the contract, not the test.** Replace the rule with plain `True` and run `aiken check`. `unlock_fails_for_a_stranger` fails, and it is telling you exactly the right thing: your vault gives its contents to anybody who asks. That one failing test is worth more than the one still passing. Put the rule back. +**Break the contract, not the test.** Replace the rule with plain `True` and run `aiken check`. `unlock_fails_for_a_stranger` fails, and it is telling you exactly the right thing: your vault gives its contents to anybody who asks. Put the rule back. -**Write the property test.** It needs a library first. Aiken understands property tests on its own, but the **generators** that produce the values are not in the standard library, and neither is the part that reduces a failure to the smallest input that still breaks. They live in a package you install: +**Write the property test.** It needs a library first, the only one this track installs. Aiken understands property tests on its own, but the **generators** that produce the values are not in the standard library, and neither is the part that reduces a failure to the smallest input that still breaks. They live in a package you add: ```bash aiken add aiken-lang/fuzz --version v2.2.0 ``` +That adds a `[[dependencies]]` block to `aiken.toml` for you, and the next `aiken check` downloads the package. `aiken add` acts on the project you are standing in. + It is only ever used by tests, so nothing it brings in reaches the compiled contract. Build after adding it and the hash is the one you compared in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, unchanged. -Add its import to the ones you already have: +Add its import above the datum types: {extractRegion(VaultSimple, "simple-fuzz-import")} @@ -164,11 +174,13 @@ Then this at the bottom of the file: {extractRegion(VaultSimple, "simple-property")} -`via fuzz.bytearray()` is the difference. Read the signature as a blank to be filled: `any_owner` is a parameter rather than a value you supply, and `fuzz.bytearray()` is the generator that fills it with fresh bytes on every run. A key hash is bytes, which is why that generator fits. The body is the same shape as your two unit tests. +`via fuzz.bytearray()` is the difference. `any_owner` is a parameter, and `fuzz.bytearray()` is the generator that fills it with fresh bytes on every run. A key hash is bytes, which is why that generator fits. The body is the same shape as your two unit tests. Run `aiken check` again: it reports the property alongside them, having tried a hundred generated keys. Three tests in total, and the rule is covered for every owner rather than the one you happened to name. -**Why bother, when the two unit tests already pass?** Because you chose that key yourself, and people choose normal values. A generator does not. It will try an empty key, a very long one, and values you would never think to write down. Your vault says the right thing to all of them, so now you know it rather than hope it. This pays off more later: when a rule compares numbers, such as an amount or a deadline, the mistakes are almost always at the first or last value it accepts, and those are exactly the values a generator tries. +**The two unit tests pass, and they are still not enough.** You chose that key yourself, and people choose normal values. A generator does not. It will try an empty key, a very long one, and values you would never think to write down. Your vault says the right thing to all of them, so now you know it rather than hope it. This pays off more later: when a rule compares numbers, such as an amount or a deadline, the mistakes are almost always at the first or last value it accepts, and those are exactly the values a generator tries. + +This lecture used a small corner of Aiken. The [language tour](https://aiken-lang.org/language-tour/primitive-types) covers the rest: primitive and custom types, control flow, modules, and a [tests page](https://aiken-lang.org/language-tour/tests) that goes further than this lecture into what the runner can do. If you would rather start from the top, the site opens at its [installation instructions](https://aiken-lang.org/installation-instructions). @@ -178,9 +190,9 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -You now have something the next two lectures need. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** both change a contract that currently works, and each one ends by running these tests again. A change that breaks the rule you just proved will not get past them quietly. +**[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** and **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** both change a contract that currently works, and each one ends by running these tests again. A change that breaks the rule you just proved will not get past them quietly. -Every contract in the rest of the track arrives with tests already written, in the same style. Each of those lectures names its own project path, so the command keeps the shape you have been using here. +Every contract in the rest of the track gets tests in this same style. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index fc1afaee74..5eac346f02 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -12,11 +12,11 @@ import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermed # Parameters -The last two lectures finished the list of what a validator is **given**: the **[datum and the redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, then the **[context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. That list is closed. Nothing else is handed to a validator when it runs. +The last two lectures finished the list of what a validator is **given**: the **[datum and the redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, then the **[context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. Nothing else is handed to a validator when it runs. -A **parameter** is not on that list, and that is the whole point of this lecture. It is a value built into the contract's own code, before the contract ever reaches the chain. Compiling leaves a **blank** where the value goes, and the contract is finished by filling that blank in. A parameter is not passed **to** the validator, it is baked **into** it, which is why you will never find it in `validator(datum, redeemer, context)`. +A **parameter** is not on that list. It is a value built into the contract's own code, before the contract ever reaches the chain. Compiling leaves a **blank** where the value goes, and the contract is finished by filling that blank in. A parameter is baked **into** the validator, which is why you will never find it in `validator(datum, redeemer, context)`. -So a parameter is one more way a fact reaches a contract, sitting on a different axis from everything above. Of the values **you** supply, the useful way to tell them apart is **when the value is fixed**: +Of the values **you** supply, the useful way to tell them apart is **when the value is fixed**: | | Fixed when | Lives in | To change it | |---|---|---|---| @@ -26,7 +26,9 @@ So a parameter is one more way a fact reaches a contract, sitting on a different The context is missing from that table on purpose. It is the transaction itself, settled by whoever builds the spend, rather than a value you choose and pass. -Why would you want one? Think about the vault so far. It releases funds to one owner, proven by one signature. Lose that key and the funds are gone for good, with nobody to ask for help. So we add a **backup key**: a second key, chosen when the vault is made, that can also take the funds out. The owner uses their key for normal spending. The backup is kept somewhere safe and is not touched until it is needed. +## Why the vault wants one + +Think about the vault so far. It releases funds to one owner, proven by one signature. Lose that key and the funds are gone for good, with nobody to ask for help. So we add a **backup key**: a second key, chosen when the vault is made, that can also take the funds out. The owner uses their key for normal spending. The backup is kept somewhere safe and is not touched until it is needed. Here are all three ways in, in one small contract: @@ -40,10 +42,10 @@ It is your vault with the backup key added, and the **[Try it](#try-it)** below
- {extractRegion(VaultAiken, "vault", "mint-handler")} + {extractRegion(VaultAiken, "vault")} -Nothing new is imported. `list.has` is the same question your vault has been asking since **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, only asked twice now, about a different key each time. +`list.has` is the same question your vault has been asking since **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, only asked twice now, about a different key each time. @@ -59,9 +61,9 @@ Read the three facts and where each one went: - `owner` comes from the **datum**, attached when the funds were locked. Each locked UTxO can name a different owner. - `Unlock` or `Recover` comes from the **redeemer**, chosen by whoever is spending, in that transaction. -The rule itself should look familiar. It asks the same _is this key among the signers?_ question your vault already asks, reading the signers straight off the transaction as the [last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) described. Only what it is compared against changes, and that is the interesting part: `Unlock` checks the signature against the **datum's** owner, `Recover` against the **parameter**. +It asks the same _is this key among the signers?_ question your vault already asks, reading the signers straight off the transaction as the [last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) described. Only what it is compared against changes: `Unlock` checks the signature against the **datum's** owner, `Recover` against the **parameter**. -Notice too that the redeemer finally matters. Earlier it had one choice, so it decided nothing. Here it picks which rule applies, and each choice needs a different signature. +The redeemer finally matters. Here it picks which rule applies, and each choice needs a different signature. ## Why a parameter changes the address @@ -78,20 +80,22 @@ flowchart LR **addr_test1wpc707…**`"] ``` -Those two addresses come from one file and two recovery keys, with nothing in common between them. That difference is the reason to use a parameter at all. +The two addresses have nothing in common, and that is the reason to use a parameter at all. Anyone can read the recovery key straight out of the contract. That is fine, because it is a public key **hash**, the same kind of value the datum holds. It names *who* may recover, and naming somebody is not the same as being them: taking the funds still needs a **signature** from that key, and only its owner can produce one. -Why not put the recovery key in the **datum** instead? Because then every vault would share one address, and each locked UTxO would carry its own recovery key, hidden inside until you opened it. Two UTxOs sitting side by side could have completely different backup keys and look identical. As a parameter, the key is part of the address, so a different key means a different address, and the address alone tells you which key can recover. +## Why not the datum + +Every vault would share one address, and each locked UTxO would carry its own recovery key, hidden inside until you opened it. Two UTxOs sitting side by side could have completely different backup keys and look identical. As a parameter, the key is part of the address, so a different key means a different address, and the address alone tells you which key can recover. -**[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** left you a rule for choosing between the datum and the redeemer: the facts that must be kept go in the **datum**, and the action being taken goes in the **redeemer**. A parameter sits above both of them, and the question it answers is different: +**[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** left you a rule for choosing between the datum and the redeemer. A parameter sits above both of them, and the question it answers is different: - **Parameter** for settings fixed when the contract is deployed, the same for every UTxO at that address: a recovery key, an oracle's address, a token policy. - **Datum** for facts that differ from one locked UTxO to the next. Ask "is this the same for every UTxO at this address?" first. If yes, it is a parameter. Only if no do you go back to the datum or redeemer question. -There is one more thing you could do, and it is worth knowing why it is worse. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. Same code, same tests, many vaults. +There is one more thing you could do, and it is worse. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. :::warning A recovery key can spend the vault `Recover` is a real spending path, so whoever holds the recovery key can take the funds. That is the point of a backup, and it is also the risk. Use a key **you** control, such as a hardware wallet kept somewhere else. Never a key belonging to somebody you would not hand the funds to today. @@ -105,17 +109,17 @@ Where that key is named follows the rule above: a parameter when it is fixed for ## What "filling the blank" actually involves -The three values travel by three different routes, and each is put in place by something different. The **datum** goes on the output when you lock. The **redeemer** goes in the spending transaction. The **parameter** is applied before either exists, to the compiled script itself, and that step is worth being precise about because it sounds heavier than it is. +The **datum** goes on the output when you lock. The **redeemer** goes in the spending transaction. The **parameter** is applied before either exists, to the compiled script itself. -Filling the blank does not compile anything and does not ask the network for anything. Your off-chain code takes the compiled script from your blueprint (`plutus.json`), with the blank still in it, supplies the missing value, and hashes what comes out. Two lines of ordinary code, no deployment, no transaction, no announcement. **That is the whole of "deploying" a parameterized contract**, and you will write those two lines in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. +Filling the blank does not compile anything and does not ask the network for anything. Your off-chain code takes the compiled script from your blueprint (`plutus.json`), with the blank still in it, supplies the missing value, and hashes what comes out. Two lines of ordinary code, and no transaction. **That is the whole of "deploying" a parameterized contract**, and you will write those two lines in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. You will meet the word "deploy" in one other sense, though. It also describes putting the script into a UTxO, so that later transactions point at it instead of carrying a copy of it. That one really is a transaction, and it is optional: a way to make every spend smaller, not a step you must take before a contract works. **Reference inputs & scripts** does it. -One consequence lands right away, though, and it lands on the redeemer. This contract's `VaultAction` finally lists two choices, so its redeemers finally use both constructor numbers from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: `Unlock` is constructor 0 and `Recover` is constructor 1. Get those two the wrong way round later and the vault will look at the wrong key, without complaining. +This contract's `VaultAction` finally lists two choices, so its redeemers finally use both constructor numbers from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: `Unlock` is constructor 0 and `Recover` is constructor 1. Get those two the wrong way round later and the vault will look at the wrong key, without complaining. ## Try it -**Give your vault a backup key.** It is the contract you already have plus one parameter and one extra action, so most of it you have written already. +**Give your vault a backup key.** It is the contract you already have plus one parameter and one extra action. @@ -133,7 +137,7 @@ Then the validator itself. It takes the parameter in brackets after its name, `_ Both branches ask `list.has`, so there is nothing new to import. - {extractRegion(VaultAiken, "vault", "mint-handler")} + {extractRegion(VaultAiken, "vault")} ```bash @@ -157,9 +161,9 @@ Add the recovery key beside `owner` and `stranger`, and a test for each side of Fix the three existing calls the same way, then run `aiken check` again. Five tests, five passes. -Look at what the last two bought you. `recover_ok_when_the_recovery_key_signs` is the obvious one. **`recover_fails_when_the_owner_signs` is the one that matters**: it asks whether the two doors are genuinely separate. A vault where the owner can also take the `Recover` path compiles exactly as happily as one where they cannot, and nothing but that test tells the two apart. +`recover_ok_when_the_recovery_key_signs` is the obvious one of the two. **`recover_fails_when_the_owner_signs` is the one that matters**: it asks whether the two doors are genuinely separate. A vault where the owner can also take the `Recover` path compiles exactly as happily as one where they cannot, and nothing but that test tells the two apart. -Your vault now has two ways in: the owner's key for normal use, and the backup key for the day it is needed. And you know they are separate, rather than hoping. +Your vault now has two ways in: the owner's key for normal use, and the backup key for the day it is needed. @@ -175,7 +179,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o aiken build ``` -Open `plutus.json` and look at the entry for `vault.vault.spend`. It has grown a `parameters` field naming the blank you left, and its `hash` is **not** the one from before you added the parameter. That is the section above made concrete: a different contract, so a different hash, so a different address. Nothing was deployed, and nothing was announced. The file changed, and that was the whole event. +Open `plutus.json` and look at the entry for `vault.vault.spend`. It has grown a `parameters` field naming the blank you left, and its `hash` is **not** the one from before you added the parameter. A different contract, so a different hash, so a different address. The file changed, and that was the whole event. The blank itself is still empty. Filling it in is the first thing **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** does, and until something does, this contract has no address at all. diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index ad37f4a85a..9587258f31 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -12,7 +12,7 @@ import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermed # Validator purposes -So far "validator" has meant _guarding a locked UTxO_. That is the most common job, but it is not the only one. A validator can guard several different kinds of action, and the kind it is guarding is called its **purpose**. The idea is the same. Only what starts it changes. +So far "validator" has meant _guarding a locked UTxO_. A validator can guard several different kinds of action, and the kind it is guarding is called its **purpose**. These are the purposes you will meet: @@ -25,11 +25,11 @@ These are the purposes you will meet: Every purpose works the same way. Something in a transaction touches your script, the network runs your validator, and it answers **yes or no**. Only the trigger and the thing being guarded change. You have already seen a simpler version of the mint purpose. The Beginner [minting example](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) used a **native script** as its policy. You use a validator instead when the rule needs to do more than check who signs and when. -The handler you write changes a little between purposes, because the question changes. A **spend** handler is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** handler is not, because nothing is being unlocked, so there is no locked UTxO and no note. Instead it is told which policy is being minted under. All of them receive the redeemer and the whole transaction. Your vault uses **spend** today. In this lecture it gains **mint** as well. +The handler you write changes a little between purposes, because the question changes. A **spend** handler is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** handler is not, because nothing is being unlocked. Instead it is told which policy is being minted under. All of them receive the redeemer and the whole transaction. Your vault uses **spend** today. In this lecture it gains **mint** as well. ## One validator, many purposes, one hash -Here is the powerful part. A **single validator** can handle **several purposes at once**, and it has exactly **one hash**. That one hash is all of these at the same time: +A **single validator** can handle **several purposes at once**, and it has exactly **one hash**. That one hash is all of these at the same time: - its **address** (for the _spend_ purpose), - its **policy ID** (for the _mint_ purpose), @@ -46,9 +46,9 @@ flowchart TD guards reward withdrawals`"] ``` -That is one compiled script doing three jobs. The hash **is** the script's identity, and where you put that hash decides which question the network asks it. Put it in an address and it guards funds. Put it on a token as the policy ID and it guards who may create that token. Register it as a stake credential and it guards rewards. +The hash **is** the script's identity, and where you put that hash decides which question the network asks it. -The result is more useful than it first sounds. Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. +Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. ## Your vault declares only one purpose, so far @@ -71,13 +71,17 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -That is what it has been doing all along: covering **every other purpose**. If anything tries to use this script as a minting policy, or a stake credential, or anything else besides spending, the answer is no. A script that allowed purposes you never thought about would be approving actions you never considered. Writing only a spend handler is not the same as making spending the only thing possible. You will see this pair, one real handler plus a refusing `else`, in most small contracts. +That is what it has been doing all along: covering **every other purpose**. If anything tries to use this script as a minting policy, or a stake credential, or anything else besides spending, the answer is no. Writing only a spend handler is not the same as making spending the only thing possible. You will see this pair, one real handler plus a refusing `else`, in most small contracts. -## Minting and locking in one transaction +## Giving the vault a token of its own -The handler you are about to write guards a token of the vault's own, and it allows exactly two things: one token created, or one token destroyed. Nothing else under this policy. +One script *can* carry every purpose at once. Whether it *should* is a design decision, and the vault is about to make the other one. -That is enough for something the vault could not do before. The script's hash is both the **policy id** that approves the token and the **address** the token is sent to, so one transaction can create the token and lock it in the vault at once. +The token gets a script to itself: a validator whose only handler is `mint`. Its hash is the token's policy id, and it has nothing to do with the vault's address. + +Splitting them costs a little. The rules stay small, each one answering about the thing it guards. And the hashes stop moving together, which matters here because **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** just put a blank in the vault. + +Neither script minds. A transaction can run both, so one transaction still creates the token and locks it in the vault at once. ```mermaid flowchart LR @@ -85,66 +89,70 @@ flowchart LR subgraph T["one transaction"] direction TB - M["mint 1 VAULT
the mint handler runs"] --> L["build one output at
the vault's own address"] + M["mint 1 VAULT
the policy runs"] --> L["build one output at
the vault's address"] end T --> V["UTxO at the vault
5 ADA + 1 VAULT + datum"] - V -->|"later: unlock
the spend handler runs"| B["your wallet
5 ADA + 1 VAULT"] + V -->|"later: unlock
the vault's spend handler runs"| B["your wallet
5 ADA + 1 VAULT"] ``` -The token is created and locked in the **same** transaction, so it never stops at your wallet on the way in. It reaches you when you unlock, together with the ADA it was guarding. +It reaches you when you unlock, together with the ADA it was guarding. ## Try it -**Give your vault a second purpose: let it mint its own token.** The `else` block refuses minting today, so you replace it with a real `mint` handler. +**Give your vault a token of its own.** The vault's `else` block goes on refusing everything but spending, and the token gets a validator of its own beside it. Everything below runs from `on-chain/vault/`, where lecture 2 left you. -The helper this rule needs is already in the project. You added **vodka** in **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** for its `mocktail` half, and this rule uses its other half. - -The three names are worth sorting out, because you now use both. **vodka** is the package. **cocktail** is its half for contracts, which is where `token_minted` comes from. **mocktail** is its half for tests, for building fake transactions. One package, two module names. - -The mint rule needs a type and that helper. In `validators/vault.ak`, add both at the top: +The rule needs two things from the standard library: the `PolicyId` type, and `dict`, because the helper that reads the minted tokens hands back a dictionary. In `validators/vault.ak`, add both at the top: - {`${extractRegion(VaultAiken, "import-policy-id")}\n${extractRegion(VaultAiken, "import-token-minted")}`} + {`${extractRegion(VaultAiken, "import-dict")}\n${extractRegion(VaultAiken, "import-policy-id")}`} -`token_minted` does the work: it answers "does this transaction mint exactly this much of this token?". - -The mint rule needs a name to check against, so give the token one, above the validator: +The rule also needs a name to check against, so give the token one, above the validator: {extractRegion(VaultAiken, "token-name")} -Now the rule for the token itself: one may be created, or one destroyed, and nothing else. Add the handler **inside the validator block**, between `spend` and `else`: +Now the rule for the token itself. Write it as a **second validator**, below the vault: - {extractRegion(VaultAiken, "mint-handler")} + {extractRegion(VaultAiken, "mint-validator")} -Read the arguments, because they differ from `spend`. **No datum reaches this handler**: a datum belongs to the UTxO being unlocked, and minting unlocks nothing. The transaction can still attach a datum to an output it creates, and the one that mints a token and locks it does, but that note belongs to the new vault UTxO and the mint rule is never handed it. Instead the handler is told its own `policy_id`, which is this script's hash. The rule allows two things and nothing else: minting one token (`1`), or burning one (`-1`). +Read the arguments, because they differ from `spend`. **No datum reaches this handler**: a datum belongs to the UTxO being unlocked, and minting unlocks nothing. The transaction can still attach a datum to an output it creates, and the one that mints a token and locks it does, but that note belongs to the new vault UTxO and the mint rule is never handed it. Instead the handler is told its own `policy_id`, which is this script's hash. + +`self.mint` holds everything the transaction creates or destroys, under every policy. `assets.tokens` gives back only the tokens minted under this one, as a dictionary of token name to amount, and `dict.to_pairs` turns that into a list. Matching the list against `[Pair(name, _)]` succeeds only if it holds exactly one entry, so the transaction cannot mint a second name under this policy. `name == vault_token` then decides which name that has to be. + +**Notice which script this is.** The vault takes `recovery` as a parameter and this policy takes none, so the two hashes move independently. Change your backup key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy id stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. ```bash aiken check aiken build ``` -Open `plutus.json` and look at the `validators` list. It now has **three** entries, `vault.vault.spend`, `vault.vault.mint` and `vault.vault.else`, and all three carry the **same hash**. One script, three doors. +Open `plutus.json` and look at the `validators` list. It now has **four** entries under two hashes. `vault.vault.spend` and `vault.vault.else` share one, `vault.vault_policy.mint` and `vault.vault_policy.else` share the other. -Compare that hash with ours: +Compare the policy's with ours: ``` -778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf +736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c ``` -If it matches, you wrote the same contract we did, byte for byte. Same hash means the same address **and** the same policy id. +This one you should match exactly: there is no blank to fill, so nothing about your setup can move it. -It is worth knowing what that hash is *not*. Your vault takes a parameter, so this is the script with the blank still in it, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. Filling the blank with a real recovery key gives a different hash, and that one is the address funds actually go to. +The vault's is the other kind: + +``` +5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4 +``` + +If that matches, you wrote the same spend rule we did, byte for byte. Your vault takes a parameter, so this is the script with the blank still in it, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. Filling the blank with a real recovery key gives a different hash, and that one is the address funds actually go to. @@ -160,42 +168,36 @@ The hash is made from the **compiled code**, not from what the contract does. Tw So the two things answer different questions. Your **tests** say the vault behaves correctly. The **hash** says you wrote it the same way we did. If yours passes the tests but misses the hash, nothing is wrong: it works, and it simply lives at a different address than ours. Only worry if the tests fail. ::: -**And unlocking needs no change at all.** The spend handler still checks the owner's signature, exactly as it did before the vault could mint anything, and the token comes back with the ADA. +**And unlocking needs no change at all.** The vault's spend handler still checks the owner's signature, exactly as it did before there was a token. -Then match each action to the purpose the network would run: +The purpose the network runs follows from what the transaction does: -- Unlock vested funds after a deadline → **spend** -- Create a one-of-a-kind NFT → **mint** -- Claim your staking rewards → **withdraw** +- Unlock vested funds after a deadline: **spend** +- Create a one-of-a-kind NFT: **mint** +- Claim your staking rewards: **withdraw** -One script, three kinds of action. +Any one script can be asked all three questions. Yours answers two of them across two scripts, which is the more common shape once a contract grows. -**Now prove the new handler.** A `mint` handler is a new rule, so it needs its own tests, and they are written exactly like the ones you already have. +**Now prove the new validator.** A `mint` handler is a new rule, so it needs its own tests, and they are written exactly like the ones you already have. -Two more helpers from mocktail: `mint` builds a transaction that mints something, and `mock_script_hash` stands in for a policy id. They live in modules you already import, and Aiken takes them on their own lines, so add these rather than editing the lines you have: - - - {extractRegion(VaultAiken, "mint-test-imports")} - - -Then the tests, at the bottom of the file: +These need nothing new imported. `assets` came in with the rule, and `transaction.placeholder` is the same empty transaction context your spend tests start from. Add them at the bottom of the file: {extractRegion(VaultAiken, "mint-tests")} -Minting one passes, burning one passes, minting two is refused: exactly the rule you wrote. `Void` is the redeemer, because that handler ignores it, and `recovery` leads each call here too, because a parameter comes first in **every** handler, not just `spend`. +Minting the vault's token passes, burning it passes, minting anything else is refused: exactly the rule you wrote. `assets.from_asset` fills the mint field the way the network would, and `Void` is the redeemer, because that handler ignores it. Nothing leads these calls, unlike the ones into `vault`: a parameter comes first in every handler of a parameterised validator. ```bash aiken check ``` -Eight tests, eight passes. Five of them are the spend rule from the last two lectures, still green, which is the other thing a test suite is for: you just added a whole new purpose to this contract and you know for certain you did not disturb the old one. +Eight tests, eight passes. Five of them are the spend rule from the last two lectures, still green, which is the other thing a test suite is for: you just added a whole new script beside the vault and you know for certain you did not disturb it. -**Then break the new rule.** Change the `1` in the first `or` branch to `2` and run `aiken check` again. `mint_ok_for_a_single_token` and `mint_fails_for_more_than_one` both go red together, which is worth a second look: one says the allowed case is now refused, the other says the forbidden case is now allowed. Put the `1` back. +**Then break the new rule.** Change `"VAULT"` in the constant to `"IMPOSTOR"` and run `aiken check` again. `mint_ok_for_a_correctly_named_token` and `mint_fails_for_a_wrongly_named_token` both go red together: one says the allowed case is now refused, the other says the forbidden case is now allowed. Put the name back. @@ -207,9 +209,9 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o ## Your contract is finished -That is the vault: a spend rule with two doors, a mint rule guarding its own token, one hash for all of it, and eight tests saying so. +That is the vault: a spend rule with two doors, a mint policy guarding its own token, and eight tests saying so. -Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the other half of the track, and it is the whole off-chain side in one go: the address, the transactions that lock, unlock, recover and mint, the tests that drive them, and a page in a browser with buttons on it. It can be written straight through now, without stopping, precisely because the contract behind it has stopped moving. +Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the whole off-chain side in one go: the address, the transactions that lock, unlock, recover and mint, the tests that drive them, and a page in a browser with buttons on it. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 6dc8fa9886..a6d48bc007 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -20,13 +20,13 @@ import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermedia # Off-chain and frontend integration -Your contract is finished. It compiles, its eight tests pass, and it has a hash. And it can do nothing at all, because **a contract cannot act**. It only answers yes or no when something asks it to. +Your contract is finished. It compiles, its eight tests pass, and it has a hash. And it can do nothing at all, because **a contract cannot act**. -That something is your app, and this lecture is the whole of it. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left `off-chain/` folder empty, everything on that side of the line arrives here, in one go. By the end you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. +That something is your app, and this lecture is the whole of it. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Everything on that side of the line arrives here. By the end you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. It arrives all at once for a reason. The contract is where the thinking is, and it changed with every lecture: a datum, a rule, a parameter, a second purpose. The off-chain half barely changes at all. It is the same few builders every time: derive the address, attach the datum, spend the UTxO. Writing them against a contract that has stopped moving is far easier than rewriting them six times as the contract grows. -**You write all of it, and there is less than you think.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove the whole thing before a wallet is ever connected. Nothing is downloaded, and every file is short enough to read. +**You write all of it.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove the whole thing before a wallet is ever connected. ## The bridge: from blueprint to address @@ -43,37 +43,37 @@ Locking is an ordinary payment that happens to be addressed to a script, with th - a **required signer** entry, because the rule reads the signer list and this is what puts you on it. - **collateral**, a deposit the network keeps if the script fails after passing its checks. -The third of those is the one people most often forget. Your wallet signing a transaction is not the same as your key hash appearing in the transaction's required-signers field. That field is `extra_signatories`, the one your vault reads in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, and asking for it is a separate step from signing. Forget it and the signature is there but the validator cannot see it, so a correct contract refuses a legitimate spend. +Your wallet signing a transaction is not the same as your key hash appearing in the transaction's required-signers field. That field is `extra_signatories`, the one your vault reads in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, and asking for it is a separate step from signing. Forget it and the signature is there but the validator cannot see it, so a correct contract refuses a legitimate spend. -Minting adds nothing conceptually, because the same hash is both the address and the policy id, the identifier saying which script may create a token. That is **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** in practice. Minting does add collateral, because it runs a script, and a plain lock does not. +Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy id, the identifier saying which script may create a token. That is **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** in practice. Minting does add collateral, because it runs a script, and a plain lock does not. ## Collateral, and what a script costs Collateral is a deposit the network takes only when a script fails after passing structural checks. The rules are in **[fees](/docs/developers/curriculum/fundamentals/core-concepts/fees#collateral)** and the two-phase model behind them is in **[transaction failures](/docs/developers/curriculum/start-building/transaction-failures#the-two-phase-model)**. Three things about it are specific to what you are building: - It must hold **only ADA**, and it must sit at a **plain key address** with no script guarding it. Otherwise the network would need to run a second script just to collect the deposit. -- **In normal use it is never taken**, because the validator runs before you send anything. In the tests below that happens on your own machine. In the page, the job goes to the **provider**, the service that reads the chain for you, which is Blockfrost here: the builder is handed that same provider a second time, in the role of **evaluator**. +- **In normal use it is never taken**, because the validator runs before you send anything. In the tests below that happens on your own machine. In the page, the job goes to the **provider**, the service that reads the chain for you, which is Blockfrost here. - This is the first project whose **code** reads the chain, which is why it needs a Blockfrost key when the Beginner track never did. The builder resolves inputs and fee settings through the provider, and the check before you send asks it to run your script as well. :::tip Set collateral once and forget it In **[Lace](https://www.lace.io/)** this is a one-time setup that sets a few ADA aside. See the [Lace FAQ](https://www.lace.io/faq). The ADA is still yours and still counted in your balance, only reserved. Without it, every script spend you build fails before it leaves your machine, with a "no collateral" error. ::: -Unlocking also costs more than locking, because it runs a program and that is priced separately in **[execution units](/docs/developers/curriculum/fundamentals/core-concepts/fees#script-execution-fees)**. Our vault is about as small as a contract can be, so here the difference is a fraction of a test ADA. The mechanism is the same at any size. +Unlocking also costs more than locking, because it runs a program and that is priced separately in **[execution units](/docs/developers/curriculum/fundamentals/core-concepts/fees#script-execution-fees)**. Our vault is about as small as a contract can be, so here the difference is a fraction of a test ADA. ## The browser half -Every builder below ends the same way: it returns an **unsigned transaction**. Your app builds, the **wallet** signs and submits, and your code never sees a key. That division is [CIP-30](/docs/developers/curriculum/dapps/connect-a-wallet#what-cip-30-gives-you), the interface every Cardano wallet exposes to a page, which is why an app written for one wallet works with the rest. +Every builder below ends the same way: it returns an **unsigned transaction**, which the **wallet** signs and submits. Your code never sees a key. That division is [CIP-30](/docs/developers/curriculum/dapps/connect-a-wallet#what-cip-30-gives-you), the interface every Cardano wallet exposes to a page, which is why an app written for one wallet works with the rest. -One detail about signing an unlock is worth knowing before you see it in code. The wallet signs **partially**: it signs the inputs it owns and leaves the rest alone. One of those inputs is the locked UTxO, and it sits at a script address, where no key can sign for anything. Whether it may be spent is the validator's decision, made when the network runs it. Ask the wallet for a complete signature instead and it refuses, because you are asking it to sign for something it has no key for. +The wallet signs an unlock **partially**: it signs the inputs it owns and leaves the rest alone. One of those inputs is the locked UTxO, and it sits at a script address, where no key can sign for anything. Whether it may be spent is the validator's decision, made when the network runs it. ## The browser cannot keep a secret -For the first half of this lecture your Blockfrost key sits in `.env`, and that is safe, because everything reading it runs on your own machine. A browser app is the opposite. Everything it needs in order to run has to be **sent to the person using it**, and anything sent can be read. There is no private part of a page, so a key written into that JavaScript is not hidden. It is published. +For the first half of this lecture your Blockfrost key sits in `.env`, and that is safe, because everything reading it runs on your own machine. A browser app is the opposite. Everything it needs in order to run has to be **sent to the person using it**, and anything sent can be read. There is no private part of a page, so a key written into that JavaScript is published. Vite, the build tool that serves and bundles your page, draws that line for you: **your page can only read variables whose names start with `VITE_`, and whatever it reads is written into the files it ships.** Everything else in `.env` stays on your machine, where the backend can still read it, and never reaches the browser at all. That is why your key is never given the prefix, and why the network id is. -So the key has to live somewhere the browser never reaches: your **page** builds transactions and holds no secrets, and a small **proxy**, running on a machine you control, holds the key and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. +So the key has to live somewhere the browser never reaches: a small **proxy**, running on a machine you control, holds it and is the only thing that talks to Blockfrost. The full version of that split, where transaction building moves server-side too, is **[frontend signs, backend builds and submits](/docs/developers/curriculum/dapps/connect-a-wallet#frontend-signs-backend-builds-and-submits)**. Here only the provider calls move, which is enough to protect the key. ## The whole flow, end to end @@ -104,7 +104,7 @@ sequenceDiagram ## Try it -**Fill the other half of your workspace.** You have been inside `on-chain/vault/` since **[set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. From there, go up two levels to the workspace root, because everything below is about the other half: +**Fill `off-chain/`.** You have been inside `on-chain/vault/` since **[set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. From there, go up two levels to the workspace root: ```bash cd ../.. # from cardano-vault/on-chain/vault/ back to cardano-vault/ @@ -128,7 +128,7 @@ mkdir off-chain/src off-chain/src/lib The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser. -Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed, so putting it at the root means every command in this track still runs from `cardano-vault/`. +Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed. One more file, so your editor understands the code you are about to write. Create `tsconfig.json` beside `package.json`: @@ -153,7 +153,7 @@ One more file, so your editor understands the code you are about to write. Creat } ``` -Four of those lines are doing real work. `skipLibCheck` stops TypeScript checking Mesh's own dependencies and reporting errors from libraries you never imported. `types` brings in Node's globals, which the tests need, and Vite's, which is what makes `import.meta.env` a known thing. `resolveJsonModule` lets you import `plutus.json`. And `allowImportingTsExtensions` is what lets your imports say `./lib/lock.ts`, extension and all, the way Node runs them. +`skipLibCheck` stops TypeScript checking Mesh's own dependencies and reporting errors from libraries you never imported. `types` brings in Node's globals, which the tests need, and Vite's, which is what makes `import.meta.env` a known thing. `resolveJsonModule` lets you import `plutus.json`. And `allowImportingTsExtensions` is what lets your imports say `./lib/lock.ts`, extension and all, the way Node runs them. ### 2. From blueprint to address @@ -163,12 +163,12 @@ The first file you write, and the bridge the top of this lecture describes. Crea {extractRegion(Blueprint, "file")} -Four things in it are worth reading slowly: +Four things in it: - **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves of your workspace touch, and it is a file, not a network call. - **The title** `vault.vault.spend` is `..`, so it names your `vault.ak`, its `vault` validator, and its spend handler. - **`applyParamsToScript`** fills the blank from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. These are the two lines that lecture promised you. -- **`RECOVERY`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out, but whatever you choose has to stay the same forever. +- **`RECOVERY`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out. :::caution Changing RECOVERY moves the vault It is part of the script, so it is part of the hash, so it is part of the address. Lock funds with one value, change a single character, and your app will look for them somewhere else entirely and find nothing. The funds are not lost, they are at the old address, but you would have to put the old value back to reach them. @@ -182,7 +182,7 @@ The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/interm {extractRegion(Datum, "file")} -`mConStr0` is the numbered-constructor encoding that lecture described. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `Recover`, because it is declared second in `VaultAction`. Get those last two the wrong way round and nothing announces it: the vault reads the other branch and checks the other key. +`mConStr0` is the numbered-constructor encoding that lecture described. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `Recover`, because it is declared second in `VaultAction`. ### 4. The four transactions @@ -200,11 +200,11 @@ Then `off-chain/src/lib/unlock.ts`, which is where the contract does run: {extractRegion(UnlockLib, "file")} -Four of the extra lines are the four things a script spend adds. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. +The four things a script spend adds each get a line. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. -Three more lines say what is being spent. `.spendingPlutusScriptV3()` declares that this input is guarded by a script, `.txIn(...)` names the locked UTxO, and `.txInInlineDatumPresent()` says its datum is already on the chain, so there is nothing to attach. +The rest say what is being spent. `.spendingPlutusScriptV3()` declares that this input is guarded by a script, `.txIn(...)` names the locked UTxO, and `.txInInlineDatumPresent()` says its datum is already on the chain, so there is nothing to attach. -One argument is worth stopping on, because the next step is built on it. Passing an **evaluator** makes the builder run your **real compiled validator** before it returns anything. A spend the contract would refuse fails here, immediately, instead of on the chain where it would cost you the collateral. +Passing an **evaluator** makes the builder run your **real compiled validator** before it returns anything. A spend the contract would refuse fails here, immediately, instead of on the chain where it would cost you the collateral. Next `off-chain/src/lib/fetch.ts`, because you cannot unlock what you cannot find. A script address is an ordinary address, so this is the same [UTxO query](/docs/developers/curriculum/start-building/query-the-chain#datums) you have made since Beginner: @@ -212,7 +212,7 @@ Next `off-chain/src/lib/fetch.ts`, because you cannot unlock what you cannot fin {extractRegion(FetchLib, "file")} -Read the filter, because it is the point. **The vault's address is not yours.** It belongs to nobody, and anyone who compiled the same contract with the same parameter arrives at the same address, so what sits there is everyone's UTxOs mixed together. The only thing that says which are yours is the `owner` in each datum, which is exactly what your validator will check later. +**The vault's address is not yours.** Anyone who compiled the same contract with the same parameter arrives at the same address, so what sits there is everyone's UTxOs mixed together. The only thing that says which are yours is the `owner` in each datum, which is exactly what your validator will check later. Last `off-chain/src/lib/mint.ts`, which is `lock.ts` plus the mint from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**, in one transaction: @@ -220,11 +220,11 @@ Last `off-chain/src/lib/mint.ts`, which is `lock.ts` plus the mint from **[valid {extractRegion(MintLib, "file")} -`vaultPolicyId()` hashes the same compiled script that gives you the address. One value, two roles, the whole point of that lecture, now in code. `.mint("1", ...)` creates exactly one token, which is precisely what your handler allows. +`vaultTokenPolicyId()` hashes the policy script, the second one in your blueprint, so the value it returns has nothing to do with the vault's address. Two scripts run in this transaction: `.mintingScript(...)` carries the policy so the network can ask it about the token, and the output goes to `vaultAddress(...)`. ### 5. Prove it offline -Nothing has run yet. **[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** named a third level of testing it could not reach, because there was no app to test. There is now, and it needs no wallet, no test ADA and no network. +**[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** named a third level of testing it could not reach, because there was no app to test. There is now, and it needs no network. Create `off-chain/src/vault.test.ts`. The imports first: @@ -250,7 +250,7 @@ Now the first test. Locking runs no contract, so this one only has to build: {extractRegion(OfflineTests, "offline-lock")} -And the second, which is the one that matters. It calls the very same `buildUnlockTx` your page will call, then evaluates it, which runs your **real compiled validator**. Getting an execution budget back means the contract said yes: +And the second. It calls the very same `buildUnlockTx` your page will call, then evaluates it, which runs your **real compiled validator**. Getting an execution budget back means the contract said yes: {extractRegion(OfflineTests, "offline-unlock")} @@ -262,13 +262,13 @@ Run it: node --test off-chain/src/vault.test.ts ``` -Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly, which is why this lecture opened by asking for 22.18 or newer. +Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly. Node prints one warning above that, about importing a WebAssembly module. It comes from Mesh loading the library that serialises transactions, and it is safe to ignore. **Now break the off-chain side, and watch which layer notices.** In `off-chain/src/lib/unlock.ts`, delete the `.requiredSignerHash(owner)` line and save. -Your contract is untouched, and its eight tests would still pass, because nothing is wrong with the rule. They never see your app, which is exactly the gap this level exists to close. +Your contract is untouched, and its eight tests would still pass, because nothing is wrong with the rule. Run the test file again. It fails, in the same few milliseconds, and the evaluator reports which script did the refusing: @@ -282,7 +282,7 @@ Put the line back and run it once more to be sure. ### 6. The key, and where it lives -Everything so far ran on your machine and nowhere else. A page is different: everything it needs is sent to whoever opens it. So the key gets its own file, which the page never reads. +So the key gets its own file, which the page never reads. First a `.env` file at the top of `cardano-vault/`, beside `package.json`, so no key is ever written into your code: @@ -296,7 +296,7 @@ VITE_NETWORK_ID=0 Nothing in this track puts `cardano-vault/` into version control, but the day you do, add `.env` to a `.gitignore` **before** the first commit. A key in a commit is a key you have given away, even if you delete it in the next one. -Nothing reads that key in the browser. What reads it is a **proxy**: a rule that catches every call your page makes to `/api/blockfrost/…`, adds the key, and passes the call on to Blockfrost. Your page therefore only ever talks to its own origin. You write that rule in the next step, because it lives in the same file that configures the page. +What reads it is a **proxy**: a rule that catches every call your page makes to `/api/blockfrost/…`, adds the key, and passes the call on to Blockfrost. Your page therefore only ever talks to its own origin. ### 7. The page, and run it @@ -309,7 +309,7 @@ npm pkg set scripts.dev=vite npm pkg set scripts.build="vite build" ``` -`vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are what your `tsconfig.json` from step 1 has been describing; nothing here runs `tsc`. `vite-plugin-node-polyfills` is the surprising one: Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have, so they have to be supplied. +`vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are what your `tsconfig.json` from step 1 has been describing; nothing here runs `tsc`. `vite-plugin-node-polyfills` is there because Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have. Two small files Vite needs, and they are the only ones whose paths depend on where things sit in your workspace. `index.html` goes at the top of `cardano-vault/`, beside `package.json`, because Vite serves the folder you run it from: @@ -359,7 +359,7 @@ export default defineConfig(({ mode }) => { }); ``` -Four lines do the work. `target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both. +`target` is where the calls really go, `rewrite` strips the `/api/blockfrost` prefix your page uses, `headers` attaches the key, and `changeOrigin` makes the request look like it came from Blockfrost's own host. The network comes from the key itself: a Blockfrost key names its own network in its first seven characters, which is why one variable configures both. :::note Where this rule still applies once you deploy It depends on what the host runs. On anything with a **Node process**, a container, a VPS, or a service that runs `npm run preview`, this same config serves the built page and proxies exactly as it does locally. On a **static host**, which is what Vercel and Netlify give a Vite app by default, there is no Node process: the page is served from a CDN and nothing answers `/api/blockfrost/…`. @@ -373,9 +373,9 @@ A redirect will not rescue the static case, because it passes the browser's head {extractRegion(VercelFn, "file")} -It is the same four decisions as the config: where the call really goes, strip the prefix, attach the key, hand the answer back. Returning `fetch(...)` straight out passes the status and body through untouched. The forwarding itself is portable, since it is plain `Request` in, `Response` out, but each host wants its own entry point: Netlify Edge Functions expect the file under `netlify/edge-functions/`, and Cloudflare Workers export `{ fetch }` and read secrets from an `env` argument rather than `process.env`. +It is the same four decisions as the config. Returning `fetch(...)` straight out passes the status and body through untouched. The forwarding itself is portable, since it is plain `Request` in, `Response` out, but each host wants its own entry point: Netlify Edge Functions expect the file under `netlify/edge-functions/`, and Cloudflare Workers export `{ fetch }` and read secrets from an `env` argument rather than `process.env`. -**And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one. That is the whole swap, and it is why those builders were typed against the interface Mesh defines rather than against a particular wallet. +**And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one, which is why those builders were typed against the interface Mesh defines rather than against a particular wallet. So the last file you write is the page. Create `off-chain/src/app.tsx`: @@ -389,9 +389,9 @@ Look at the provider line first, because it is the entire client-side cost of ke const provider = new BlockfrostProvider("/api/blockfrost"); ``` -No key, and no change anywhere else. Mesh supports this directly: hand `BlockfrostProvider` a path instead of a project id and it treats it as a privately hosted Blockfrost, which is exactly what yours now is. Nothing in `off-chain/src/lib/` had to move for that, which is why those builders take a `provider` instead of creating one of their own. +No key, and no change anywhere else. Mesh supports this directly: hand `BlockfrostProvider` a path instead of a project id and it treats it as a privately hosted Blockfrost, which is exactly what yours now is. Those builders take a `provider` instead of creating one of their own, so nothing in them had to move. -Three more things in it are the browser half from above, in code: +Three more things in it are the browser section above, in code: - `BrowserWallet.enable("lace")` is the permission handshake. Swapping `"lace"` for another wallet id is the only change another wallet needs. - `wallet.signTx(unsignedTx, true)` is the **partial** signature. Drop that `true` on the unlock and the wallet refuses, because you are asking it to sign a script input it holds no key for. @@ -407,13 +407,13 @@ Open the printed URL **in the browser where Lace is installed**, with Lace set t 1. **Connect wallet.** The extension asks for permission once. 2. **Lock 5 ADA.** Approve it. This is the plain payment: no contract runs. -3. **Refresh locked** after a few seconds, and your UTxO appears. That is your ADA sitting at an address nobody owns. +3. **Refresh locked** after a few seconds, and your UTxO appears. 4. **Unlock.** This one runs your validator. The funds come back. 5. **Mint & lock 5 ADA.** The same lock, plus a VAULT token created under the contract's own policy, in one transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. If the page loads but **Lock** fails, look at `.env` before anything else. A Preview key starts with `preview`, and a mainnet or mistyped key shows up as a 401 on `/api/blockfrost/…` in the browser's **Network** tab. -**Then prove the key is gone.** Open the developer tools, go to the **Network** tab, and press **Refresh locked**. Every request goes to `/api/blockfrost/…` on your own origin, and none to `blockfrost.io`. The browser is not talking to the provider at all. It cannot, because it has nothing to authenticate with. +**Then prove the key is gone.** Open the developer tools, go to the **Network** tab, and press **Refresh locked**. Every request goes to `/api/blockfrost/…` on your own origin, and none to `blockfrost.io`. The browser cannot reach the provider, because it has nothing to authenticate with. Now check the code that goes to the browser, which is the part that would have been public: @@ -423,7 +423,7 @@ npm run build Then search `dist/` for your key. It is not there. Without the proxy it would have been, sitting in `dist/assets/index-*.js`, where anyone who opened your page could have read it. Search for the bare word `preview` instead and you will get hits, but those are Mesh's own network names, not your key. -**And notice which rules applied where.** Your proxy reads the key straight out of `.env` and that is correct: it runs on your machine, for you. The page goes to anyone who opens it, so it gets none of it. Same key, same file, trusted in one place and not in the other, and the only thing that decides which rules apply is **where the code runs**. +**And notice which rules applied where.** Your proxy reads the key straight out of `.env` and that is correct: it runs on your machine, for you. The page goes to anyone who opens it, so it gets none of it. The only thing that decides which rules apply is **where the code runs**. **Then break it on purpose, one last time.** You already watched the offline tests catch a missing `.requiredSignerHash(owner)`. Delete that line again and press **Unlock** here. Nothing reaches the chain: the check before sending, where your proxy asks Blockfrost to run the script, already said no. The owner's key was never in `extra_signatories`, so `list.has` was false. Same refusal, same rule, now with a wallet connected and real test ADA at stake. Put the line back. @@ -437,13 +437,13 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. -## That is the vault, finished +## What you built -You started with an empty folder. You now have a contract you wrote and tested, with two purposes under one hash, and an app that locks, mints and unlocks real test ADA through it. +You started with an empty folder. You now have a contract you wrote and tested, a minting policy beside it, and an app that locks, mints and unlocks real test ADA through them. -Notice the balance. Six lectures went into the contract, and every one of them added something to it. One went into the app, because its shape never changed: derive the address, build a transaction, hand it to a wallet. That is the usual balance of Cardano work, and it is why the rest of this track goes back to contracts. +Six lectures went into the contract, and every one of them added something to it. One went into the app, because its shape never changed: derive the address, build a transaction, hand it to a wallet. -Each of the remaining lectures is the same shape with a different rule in the middle. The contracts arrive finished, and each lecture has you break one and write the missing rule back: +Each of the remaining lectures is the same shape with a different rule in the middle: - **Handling time**: funds that cannot move before a date. - **Multi validators**: a token that acts as a key, where burning it is what opens the lock. diff --git a/docs/developers/onboarding/lectures/intermediate/introduction.md b/docs/developers/onboarding/lectures/intermediate/introduction.md index e170bfbc5d..75d8cc75b6 100644 --- a/docs/developers/onboarding/lectures/intermediate/introduction.md +++ b/docs/developers/onboarding/lectures/intermediate/introduction.md @@ -9,7 +9,7 @@ import TabItem from "@theme/TabItem"; # Intermediate: smart contracts -You finished Beginner, so you can move value around Cardano. This track makes the chain **enforce rules** about how that value moves. That is what a smart contract is. We build up from the ideas to a real contract you compile and run yourself. +You finished Beginner, so you can move value around Cardano. This track makes the chain **enforce rules** about how that value moves. That is what a smart contract is. :::note Coming from Ethereum? "Smart contract" means something different here. On Cardano, it is not a deployed program with storage that you call and that then acts. It is a **rule that answers yes or no** to a transaction your app has already built. State still exists, but it lives in the **datum** on a UTxO rather than inside the contract. **[Cardano for Ethereum developers](/docs/developers/cardano-for-ethereum-developers)** covers how Ethereum and Cardano development differ. This track teaches it from scratch. @@ -17,8 +17,6 @@ You finished Beginner, so you can move value around Cardano. This track makes th ## What you'll be able to do -After this track you'll be able to: - - Understand how Cardano dApps work under the hood and how you can build your own. - Read and write Cardano smart contracts. - Build transactions to interact with smart contracts. @@ -50,7 +48,7 @@ Lectures 10 to 12 each start from an idea and walk the same path, from the idea Four contracts, and you write all of them: a **vault** that releases funds only to the owner who signs, a **vesting** contract that holds funds until a date, a **gift card** whose token is the key to the funds behind it, and an **oracle** that publishes a value and keeps changing it. -The vault is the long one: lectures 1 to 9 build it a step at a time, one concept per lecture. Lectures 2 to 8 are on-chain only, so you write the validator, compile it and test it with no app yet, and lecture 9 is where you connect it to a website. That order is deliberate: the contract is where the thinking is, and the app that drives it follows from it. +The vault is the long one: lectures 1 to 9 build it a step at a time, one concept per lecture. Lectures 2 to 8 are on-chain only, so you write the validator, compile it and test it with no app yet, and lecture 9 is where you connect it to a website. ## What you need @@ -78,7 +76,7 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o Everything in these lectures is also finished and working in one example project, which we call the **playground**. It has every contract in the track, plus a small browser app that drives them: connect a wallet, mint and lock funds, unlock them again, put a deadline on funds, update an oracle, etc. -You do not need it to follow the lectures. It is here for two reasons: +You do not need it to follow the lectures: - **To see where you are going.** Run it once now, and the rest of the track is you rebuilding the first part of it yourself. - **To get unstuck.** Every exercise solution is provided in the playground's code. @@ -107,7 +105,7 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming -Inside the folder, one directory per contract, and the code you read in these lectures is imported straight from it: +The code you read in these lectures is imported straight from it: ``` playground/ @@ -124,7 +122,7 @@ playground/ └── off-chain/mesh/ ``` -**Each folder is a project in its own right.** Its contract and the app that drives it sit side by side, and nothing in it reaches into a sibling, so you can open one, run it, and take it apart without the other three in your way. +**Each folder is a project in its own right.** Its contract and the app that drives it sit side by side, and nothing in it reaches into a sibling, so you can open one, run it, and take it apart without the other three in your way. The cost of that separation is that every app is separately installed and separately configured. Each `off-chain/mesh/` wants its own `npm install`, its own `.env`, and its own wallet connection. The `.env.example` files are identical, so once you have filled one in you can copy it across: @@ -136,4 +134,4 @@ Lectures 10 to 13 work directly in these folders, with `playground/` as the fold Once `npm run dev` is running, open the printed URL **in the browser where Lace is installed**. Connect, set up collateral, then **Lock 5 ADA** and **Unlock** it again. The **Mint & lock** button does the same thing but also creates a token under the contract's own policy, which is what **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is about. -Ready? Start with **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**. +Start with **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)**. diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json index c092dcfdf7..d4fd696739 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json @@ -33,16 +33,13 @@ } } ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" }, { - "title": "vault.vault.mint", + "title": "vault.vault.else", "redeemer": { - "title": "_redeemer", - "schema": { - "$ref": "#/definitions/Data" - } + "schema": {} }, "parameters": [ { @@ -52,24 +49,27 @@ } } ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" }, { - "title": "vault.vault.else", + "title": "vault.vault_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + }, + { + "title": "vault.vault_policy.else", "redeemer": { "schema": {} }, - "parameters": [ - { - "title": "recovery", - "schema": { - "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" - } - } - ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" }, { "title": "vault_simple.vault.spend", diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx index 5467c9aca5..cf23ef2d70 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -59,8 +59,8 @@ function App() { run(() => buildLockTx(wallet!, provider, NETWORK_ID, "5000000")); } - // The same lock, plus one token minted under the vault's own policy. One - // transaction, two purposes of one script, from **validator purposes**. + // The same lock, plus one token minted under the vault's policy script. One + // transaction running two scripts, from **validator purposes**. function mintAndLock() { run(() => buildMintAndLockTx(wallet!, provider, NETWORK_ID, "5000000")); } diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts index bcb842f570..c14e8ce2b0 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts @@ -1,10 +1,6 @@ -// The first off-chain script in the track, and the only one that is not part of -// the app: it opens **frontend integration** by proving the provider works -// before anything is built on top of it. It talks to the chain and nothing -// else: no contract, no wallet, no transaction. Run it with `node src/check.ts` -// on Node 22.6 or newer. -// -// Its two values come from `.env`, so no key is ever written into the code. +// Proves the provider works before anything is built on top of it: no contract, +// no wallet, no transaction. Its two values come from `.env`. Run it with +// `node src/check.ts` on Node 22.6 or newer. // #region check import { BlockfrostProvider } from "@meshsdk/core"; diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts index 54381b4164..8d9218e6f5 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts @@ -32,6 +32,10 @@ function compiledCode(source: Blueprint, title: string): string { export const vaultScriptCbor = applyParamsToScript(compiledCode(blueprint, "vault.vault.spend"), [RECOVERY]); // #endregion params +/// The token's policy, a second script. It takes no parameter, so the list of +/// values to fill in is empty and every reader compiles the same bytes. +export const vaultTokenScriptCbor = applyParamsToScript(compiledCode(blueprint, "vault.vault_policy.mint"), []); + /// The script's address: the hash of that script, written for one network. export function vaultAddress(networkId: number): string { return serializePlutusScript( @@ -41,8 +45,8 @@ export function vaultAddress(networkId: number): string { ).address; } -/// The same hash, read as a **policy id**. -export function vaultPolicyId(): string { - return resolveScriptHash(vaultScriptCbor, PLUTUS_VERSION); +/// The policy script's hash, which is the policy id the token is filed under. +export function vaultTokenPolicyId(): string { + return resolveScriptHash(vaultTokenScriptCbor, PLUTUS_VERSION); } // #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts index e871994480..aade2caf5f 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts @@ -2,10 +2,10 @@ import { MeshTxBuilder, deserializeAddress, mConStr0, stringToHex } from "@meshsdk/core"; import type { IFetcher, IWallet } from "@meshsdk/core"; -import { vaultAddress, vaultPolicyId, vaultScriptCbor } from "./blueprint.ts"; +import { vaultAddress, vaultTokenPolicyId, vaultTokenScriptCbor } from "./blueprint.ts"; import { vaultDatum } from "./datum.ts"; -/// The token name the vault's `mint` handler allows, as the contract spells it. +/// The token name the policy allows, as the contract spells it. export const VAULT_TOKEN_NAME = "VAULT"; /// Build a transaction that **mints one vault token and locks it**, together with @@ -30,8 +30,9 @@ export async function buildMintAndLockTx( ); } - // The contract's hash, read as a policy id. Same value as its address. - const policyId = vaultPolicyId(); + // The policy script's hash. The vault's address is a different script, so + // this is a different value. + const policyId = vaultTokenPolicyId(); // Token names travel as hex on the chain, so convert it once here. const tokenNameHex = stringToHex(VAULT_TOKEN_NAME); @@ -42,8 +43,8 @@ export async function buildMintAndLockTx( .mintPlutusScriptV3() // Create exactly one token, which is precisely what the handler allows. .mint("1", policyId, tokenNameHex) - // Carry the compiled contract, so the network can run the mint handler. - .mintingScript(vaultScriptCbor) + // Carry the compiled policy, so the network can run its mint handler. + .mintingScript(vaultTokenScriptCbor) // The mint handler ignores its redeemer, so an empty one is enough. .mintRedeemerValue(mConStr0([])) // #endregion mint-calls diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts index c4ca235bae..56632e8a98 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts @@ -5,15 +5,8 @@ import type { IEvaluator, IFetcher, IWallet, UTxO } from "@meshsdk/core"; import { vaultScriptCbor } from "./blueprint.ts"; import { recoverRedeemer } from "./datum.ts"; -/// Take the funds out through the **backup key** instead of the owner's. -/// -/// Compare this with `unlock.ts` and only one line differs: the redeemer says -/// `Recover` rather than `Unlock`. Everything else is the same, because the two -/// paths spend the same UTxO at the same address, carrying the same script. -/// -/// What changes is which signature the validator then looks for. `Unlock` checks -/// the datum's owner; `Recover` checks the key built into the script. So this -/// must be signed by the recovery wallet, not the owner's. +/// `unlock.ts` with one line changed. See that file for what each builder call +/// does; the difference is marked below. export async function buildRecoverTx( wallet: IWallet, provider: IFetcher, @@ -22,8 +15,8 @@ export async function buildRecoverTx( ): Promise { // Where the wallet wants anything left over sent back to. const changeAddress = await wallet.getChangeAddress(); - // The key hash inside it. Note this is the *backup* wallet's, not the owner's - // whichever wallet you handed in is the one whose signature this asks for. + // The *backup* wallet's key hash. Whichever wallet you hand in is the one + // whose signature this asks for. const recovery = deserializeAddress(changeAddress).pubKeyHash; // The deposit, the same as any other spend that runs a script. const collateral = (await wallet.getCollateral())[0]; @@ -51,8 +44,8 @@ export async function buildRecoverTx( // The datum is already on the UTxO, so there is nothing to attach here. .txInInlineDatumPresent() // #region recover-redeemer - // **The one line that differs from `unlock.ts`**: `Recover`, not `Unlock`. - // This is what tells the validator which of its two branches to take. + // **The one line that differs from `unlock.ts`**: `Recover`, which tells the + // validator to check the key built into the script instead of the owner. .txInRedeemerValue(recoverRedeemer) // And so the signature it looks for is the recovery key's. .requiredSignerHash(recovery) diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx index ef71119795..b829068d16 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx @@ -13,8 +13,8 @@ import "./index.css"; const NETWORK_ID = Number(import.meta.env.VITE_NETWORK_ID ?? "0"); -// No key here. The provider points at our own backend, which holds it, see -// `server/blockfrost.ts`. +// No key here. The provider points at our own backend, which holds it: see +// `api/blockfrost/[...path].ts`. const provider = new BlockfrostProvider("/api/blockfrost"); const EXPLORER = "https://explorer.cardano.org/preview/transaction?id="; @@ -152,9 +152,9 @@ function App() { Mint & lock 5 ADA

- The second button also mints one VAULT token and locks it with the ADA. Same script - hash, two jobs: the address the funds go to, and the policy id the token is created under. - Unlocking brings both back. + The second button also mints one VAULT token and locks it with the ADA. Two scripts + run in that one transaction: the policy decides the token may exist, and the vault's + address is where it lands. Unlocking brings both back.

diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock index 9628fe316c..2df31f75b9 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock @@ -6,11 +6,6 @@ name = "aiken-lang/stdlib" version = "v3.1.0" source = "github" -[[requirements]] -name = "sidan-lab/vodka" -version = "0.1.23" -source = "github" - [[requirements]] name = "aiken-lang/fuzz" version = "v2.2.0" @@ -22,12 +17,6 @@ version = "v3.1.0" requirements = [] source = "github" -[[packages]] -name = "sidan-lab/vodka" -version = "0.1.23" -requirements = [] -source = "github" - [[packages]] name = "aiken-lang/fuzz" version = "v2.2.0" diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml index 19428d296d..a8be6a4d66 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml @@ -15,11 +15,6 @@ name = "aiken-lang/stdlib" version = "v3.1.0" source = "github" -[[dependencies]] -name = "sidan-lab/vodka" -version = "0.1.23" -source = "github" - [[dependencies]] name = "aiken-lang/fuzz" version = "v2.2.0" diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json index c092dcfdf7..d4fd696739 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json @@ -33,16 +33,13 @@ } } ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" }, { - "title": "vault.vault.mint", + "title": "vault.vault.else", "redeemer": { - "title": "_redeemer", - "schema": { - "$ref": "#/definitions/Data" - } + "schema": {} }, "parameters": [ { @@ -52,24 +49,27 @@ } } ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" }, { - "title": "vault.vault.else", + "title": "vault.vault_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + }, + { + "title": "vault.vault_policy.else", "redeemer": { "schema": {} }, - "parameters": [ - { - "title": "recovery", - "schema": { - "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" - } - } - ], - "compiledCode": "5902cc010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae00248888888966002646465300130063754003370e90014dc3a4001300a0039805001244444b30013004005899194c004566002600a601a6ea800626644b30013007300f375400b1330013758600460206ea8018dd7180998081baa3013301037540071330013758600460206ea801803500e1180918099809980998099809980998099809800911919800800801912cc00400629422b30013371e6eb8c05400400e2946266004004602c002808101345900c4c034dd5001cc044009222598009804001456600260226ea801a0071640491598009804801456600260226ea801a00716404916403c80786020602200260186ea801a2b30013003005899912cc004c8cc004004c8cc004004dd5980198081baa0062259800800c52f5c1132332232330010013756602a00844b30010018801c4c8cc064dd39980c9ba90053301930160013301930170014bd7019801801980d801180c800a02e375c602200266006006602c00460280028090896600200314a113259800acc004cdc79bae30140010058acc004cdc79bae301430150014881055641554c5400898049bad3014301530150018a50403d14a0807a2946266006006602a0048078dd61809800a0228a518991980080099198008009bab30033010375400c44b30010018a5eb82264664464660020026eacc054010896600200310038991980c9ba733019375200a66032602c00266032602e00297ae033003003301b0023019001405c6eb8c044004cc00c00cc058008c050005012112cc0040062942264b30015980099b8f375c602800200b15980099b8f375c6028602a002911055641554c5400899b87375a6028602a602a0029000c528201e8a50403d14a31330030033015002403c6eb0c04c0050112018375c601e60186ea80188c040c044c044c044c0440062c805100a0c024c028004c024004c010dd5004c52689b2b20041", - "hash": "778c493236d034d9be1ad753ff95ce7443056ad8653dab59b03841bf" + "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" }, { "title": "vault_simple.vault.spend", diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak index 3985ac84dc..1fbe668109 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak @@ -1,6 +1,10 @@ +// #region import-dict +use aiken/collection/dict +// #endregion import-dict + use aiken/collection/list -use aiken/fuzz use aiken/crypto.{VerificationKeyHash} +use aiken/fuzz // #region import-policy-id use cardano/assets.{PolicyId} @@ -8,24 +12,6 @@ use cardano/assets.{PolicyId} use cardano/transaction.{OutputReference, Transaction} -// #region import-token-minted -use cocktail.{token_minted} -// #endregion import-token-minted - -// #region spend-test-imports -use mocktail.{complete, mocktail_tx, required_signer_hash} -use mocktail/virgin_key_hash.{mock_pub_key_hash} -use mocktail/virgin_output_reference.{mock_utxo_ref} -// #endregion spend-test-imports - -// The mint tests need two more helpers. They live in modules already imported -// above, and Aiken takes them on their own lines, so the reader adds these -// rather than editing the lines they already have. -// #region mint-test-imports -use mocktail.{mint} -use mocktail/virgin_key_hash.{mock_script_hash} -// #endregion mint-test-imports - // The datum: who owns *this* locked UTxO. It changes from one UTxO to the next, // so it belongs on the UTxO. // #region types @@ -40,23 +26,18 @@ pub type VaultAction { Unlock Recover } - // #endregion types -// The name of the token this vault can mint. One script, one token. +// The name of the token this policy can mint. One policy, one token name. // #region token-name const vault_token: ByteArray = "VAULT" // #endregion token-name -// The vault the reader builds across the track: the owner's key, a backup key -// welded in at build time, and a token of its own. +// The vault the reader builds across the track: the owner's key for normal use, +// and a backup key welded in at build time. // -// `spend` guards the locked UTxO. Three facts reach it, each fixed at a -// different moment: `recovery` at build time, `owner` at lock time, and the -// action at spend time. -// -// `mint` guards the token: the same hash is this script's policy id, so it also -// decides which tokens may exist under it. One token in, or one token out. +// Three facts reach `spend`, each fixed at a different moment: `recovery` at +// build time, `owner` at lock time, and the action at spend time. // #region vault validator vault(recovery: VerificationKeyHash) { spend( @@ -72,73 +53,96 @@ validator vault(recovery: VerificationKeyHash) { } } - // #region mint-handler + else(_) { + fail + } +} +// #endregion vault + +// A second script, with no parameter, so its hash is the same for everybody +// while the vault's moves with the recovery key. That hash is also this script's +// policy id, so it decides which tokens may exist under it: one token in, or one +// token out. +// #region mint-validator +validator vault_policy { mint(_redeemer: Data, policy_id: PolicyId, self: Transaction) { - or { - token_minted(self.mint, policy_id, vault_token, 1), - token_minted(self.mint, policy_id, vault_token, -1), - } + expect [Pair(name, _)] = assets.tokens(self.mint, policy_id) + |> dict.to_pairs() + name == vault_token } - // #endregion mint-handler else(_) { fail } } - -// #endregion vault +// #endregion mint-validator // Tests. The reader starts them in **testing**, against the vault as it stands // there, and extends them as the contract grows: the recovery pair in // **parameters**, the mint trio in **validator purposes**. // -// Every call passes `recovery` first, the way a parameter always comes first in -// a handler. That is the edit **parameters** makes to the tests written in -// **testing**, where the vault had no parameter to pass. +// Each one copies `transaction.placeholder` and fills in only the field its rule +// reads. `recovery` leads every call into `vault`, the way a parameter always +// comes first in a handler, and none into `vault_policy`, which has no parameter. // #region spend-tests -const owner: VerificationKeyHash = mock_pub_key_hash(1) +const owner: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000001" + +const stranger: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000002" -const stranger: VerificationKeyHash = mock_pub_key_hash(2) // #endregion spend-tests // The backup key the vault is compiled around. It arrives in **parameters**, // alongside the `Recover` action it guards. // #region recover-tests -const recovery: VerificationKeyHash = mock_pub_key_hash(3) +const recovery: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000003" // #endregion recover-tests // #region spend-tests -const dummy_ref: OutputReference = mock_utxo_ref(0, 0) +const dummy_ref: OutputReference = + OutputReference { + transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000", + output_index: 0, + } // #endregion spend-tests +// Stands in for the policy id the network would hand the handler. // #region mint-tests -const policy: PolicyId = mock_script_hash(0) +const policy: PolicyId = + #"000000000000000000000000000000000000000000000000000000ff" // #endregion mint-tests // #region spend-tests test unlock_ok_when_the_owner_signs() { - let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) } test unlock_fails_for_a_stranger() fail { - let tx = mocktail_tx() |> required_signer_hash(True, stranger) |> complete() + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [stranger], + } vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) } - // #endregion spend-tests // #region recover-tests test recover_ok_when_the_recovery_key_signs() { - let tx = mocktail_tx() |> required_signer_hash(True, recovery) |> complete() + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [recovery], + } vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) } // The important one: the two actions are genuinely separate. Being the owner // does not let you take the `Recover` path, and vice versa. test recover_fails_when_the_owner_signs() fail { - let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) } // #endregion recover-tests @@ -148,25 +152,42 @@ test recover_fails_when_the_owner_signs() fail { // failure, shrinks it to the smallest one that still fails. // #region vault-property test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { - let tx = mocktail_tx() |> required_signer_hash(True, any_owner) |> complete() - vault.spend(recovery, Some(VaultDatum { owner: any_owner }), Unlock, dummy_ref, tx) + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [any_owner], + } + vault.spend( + recovery, + Some(VaultDatum { owner: any_owner }), + Unlock, + dummy_ref, + tx, + ) } // #endregion vault-property -// The parameter comes first in every handler, `mint` included. // #region mint-tests -test mint_ok_for_a_single_token() { - let tx = mocktail_tx() |> mint(True, 1, policy, vault_token) |> complete() - vault.mint(recovery, Void, policy, tx) +test mint_ok_for_a_correctly_named_token() { + let tx = Transaction { + ..transaction.placeholder, + mint: assets.from_asset(policy, vault_token, 1), + } + vault_policy.mint(Void, policy, tx) } -test burn_ok_for_a_single_token() { - let tx = mocktail_tx() |> mint(True, -1, policy, vault_token) |> complete() - vault.mint(recovery, Void, policy, tx) +test burn_ok_for_a_correctly_named_token() { + let tx = Transaction { + ..transaction.placeholder, + mint: assets.from_asset(policy, vault_token, -1), + } + vault_policy.mint(Void, policy, tx) } -test mint_fails_for_more_than_one() fail { - let tx = mocktail_tx() |> mint(True, 2, policy, vault_token) |> complete() - vault.mint(recovery, Void, policy, tx) +test mint_fails_for_a_wrongly_named_token() fail { + let tx = Transaction { + ..transaction.placeholder, + mint: assets.from_asset(policy, "IMPOSTOR", 1), + } + vault_policy.mint(Void, policy, tx) } // #endregion mint-tests diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak index c3dacbccb3..1295f1633c 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak @@ -1,34 +1,19 @@ -// This is not a second contract. It is `vault.ak` as the reader has it part way -// through the track: the vault with a working rule, but no `recovery` parameter -// and no mint handler yet. Those arrive in **parameters** and **validator -// purposes**, and `vault.ak` beside this file is where the track ends up. -// -// Lectures 4, 5 and 6 render their code from here, so what those pages show is -// the file as it looks at that moment rather than the finished version carrying -// fields the reader has not met. It lives in `validators/` so the compiler -// checks it: the code in a lecture should be code that compiles, not code that -// only looks right. -// -// It therefore gets its own `vault_simple.*` entries in `plutus.json`. Nothing -// reads them. The entries that matter are `vault.vault.*`, from `vault.ak`. +// `vault.ak` part way through the track: no `recovery` parameter, no minting +// policy. Lectures 4, 5 and 6 render from here, so their pages show the file as +// it looked at that point. It sits in `validators/` so the compiler checks it, +// which is also why `plutus.json` carries `vault_simple.*` entries nothing reads. use aiken/collection/list // #region datum-imports use aiken/crypto.{VerificationKeyHash} -use cardano/transaction.{OutputReference, Transaction} // #endregion datum-imports -// What the tests need. `mocktail` builds a pretend transaction context, the -// thing the transaction context lecture just finished describing, and the two -// `virgin_*` modules invent the key hashes and output reference to fill it. -// #region simple-test-imports +// The generators the property test draws its keys from. Added in **testing**. // #region simple-fuzz-import use aiken/fuzz +use cardano/transaction.{OutputReference, Transaction} + // #endregion simple-fuzz-import -use mocktail.{complete, mocktail_tx, required_signer_hash} -use mocktail/virgin_key_hash.{mock_pub_key_hash} -use mocktail/virgin_output_reference.{mock_utxo_ref} -// #endregion simple-test-imports // The datum: who owns *this* locked UTxO. It changes from one UTxO to the next, // so it belongs on the UTxO. @@ -36,6 +21,7 @@ use mocktail/virgin_output_reference.{mock_utxo_ref} pub type VaultDatum { owner: VerificationKeyHash, } + // #endregion types // The redeemer: the action the spender is taking. At this stage there is only @@ -49,6 +35,11 @@ pub type VaultAction { // The vault at lecture 5: it reads the owner out of the datum and asks whether // that owner signed the transaction. One line of rule. +// +// The two `trace` lines belong to **testing**, which renders `trace-example` to +// show them in place. Lecture 5 renders `validator` with `traces` omitted, so it +// still shows the handler without them, and `aiken build` strips them either +// way, which is why the hash lecture 5 publishes does not move. // #region validator validator vault { spend( @@ -57,16 +48,23 @@ validator vault { _own_ref: OutputReference, self: Transaction, ) { + // #region trace-example expect Some(VaultDatum { owner }) = datum + // #region traces + trace @"checking the vault" + trace @"signers": self.extra_signatories + // #endregion traces // #region rule list.has(self.extra_signatories, owner) // #endregion rule + // #endregion trace-example } else(_) { fail } } + // #endregion validator // Tests. The reader writes these in **testing**, against the vault as it stands @@ -75,24 +73,36 @@ validator vault { // #region simple-tests // Two keys that are not each other, and one output reference the vault never -// reads. `mock_pub_key_hash` just turns a number into a valid 28-byte hash. -const owner: VerificationKeyHash = mock_pub_key_hash(1) +// reads. A key hash is 28 bytes, so any 28 bytes stand in for one. +const owner: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000001" -const stranger: VerificationKeyHash = mock_pub_key_hash(2) +const stranger: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000002" -const dummy_ref: OutputReference = mock_utxo_ref(0, 0) +const dummy_ref: OutputReference = + OutputReference { + transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000", + output_index: 0, + } +// `transaction.placeholder` is an empty transaction context. `..` copies it and +// fills in the one field this rule reads. test unlock_ok_when_the_owner_signs() { - let tx = mocktail_tx() |> required_signer_hash(True, owner) |> complete() + let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) } // The rule is only worth anything if it also says no. `fail` is how a test // asserts refusal: this passes when the validator rejects the transaction. test unlock_fails_for_a_stranger() fail { - let tx = mocktail_tx() |> required_signer_hash(True, stranger) |> complete() + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [stranger], + } vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) } + // #endregion simple-tests // A property, not an example: the rule has to hold for *every* owner, not just @@ -100,7 +110,26 @@ test unlock_fails_for_a_stranger() fail { // failure, shrinks it to the smallest one that still fails. // #region simple-property test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { - let tx = mocktail_tx() |> required_signer_hash(True, any_owner) |> complete() + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [any_owner], + } vault.spend(Some(VaultDatum { owner: any_owner }), Unlock, dummy_ref, tx) } // #endregion simple-property + +// The smallest test in the language. **Testing** shows it first, so the reader +// meets the shape before the vault's own tests arrive. +// #region test-shape +test one_plus_one_is_two() { + 1 + 1 == 2 +} +// #endregion test-shape + +// The question the vault's rule asks, written with the pipe operator, so `|>` +// is familiar by the time the minting policy uses it in **validator purposes**. +// #region pipe +test the_owner_is_in_the_list() { + [owner, stranger] |> list.has(owner) +} +// #endregion pipe From 879202eee81071e7b3e253ca8c152d7886f7a2cc Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:20:22 -0300 Subject: [PATCH 62/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 5eac346f02..dd733738d5 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -12,7 +12,7 @@ import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermed # Parameters -The last two lectures finished the list of what a validator is **given**: the **[datum and the redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, then the **[context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. Nothing else is handed to a validator when it runs. +The last two lectures finished the list of what a validator is **given**: the **[datum and the redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, then the **[context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**. Nothing else is handed to a validator **when it runs**. A **parameter** is not on that list. It is a value built into the contract's own code, before the contract ever reaches the chain. Compiling leaves a **blank** where the value goes, and the contract is finished by filling that blank in. A parameter is baked **into** the validator, which is why you will never find it in `validator(datum, redeemer, context)`. From bd96abf861d78eba198f107dd897c29b2a9d22ae Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:21:36 -0300 Subject: [PATCH 63/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 9587258f31..814534675c 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -129,7 +129,7 @@ Read the arguments, because they differ from `spend`. **No datum reaches this ha `self.mint` holds everything the transaction creates or destroys, under every policy. `assets.tokens` gives back only the tokens minted under this one, as a dictionary of token name to amount, and `dict.to_pairs` turns that into a list. Matching the list against `[Pair(name, _)]` succeeds only if it holds exactly one entry, so the transaction cannot mint a second name under this policy. `name == vault_token` then decides which name that has to be. -**Notice which script this is.** The vault takes `recovery` as a parameter and this policy takes none, so the two hashes move independently. Change your backup key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy id stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. +**Notice which script this is.** The vault takes `recovery` as a parameter, and this policy takes none, so no need to apply parameters to this one. Change your backup key, and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy ID stays exactly where it was, because there is nothing to change. Every reader of this track ends up with a different vault and the same token. ```bash aiken check From 96c2301f5a35637a80e0389c443ba8ad64724452 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:21:54 -0300 Subject: [PATCH 64/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 814534675c..9af21408d0 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -50,7 +50,7 @@ The hash **is** the script's identity, and where you put that hash decides which Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. -## Your vault declares only one purpose, so far +## Your vault declares only one purpose The vault you have been building handles only **spend**. Its source says so in two places: the spend handler you wrote, and the `else` block that **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** asked you to copy without explaining: From 20dea1bc8c917dba6f8aa29e7a07899d41fe56b7 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:22:14 -0300 Subject: [PATCH 65/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 9af21408d0..b8f8fecbfb 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -46,7 +46,7 @@ flowchart TD guards reward withdrawals`"] ``` -The hash **is** the script's identity, and where you put that hash decides which question the network asks it. +The hash **is** the script's identity, and the way you use that hash decides which question the network asks it. Because the script sees its own hash in more than one role, it can **connect** them. One script can create a token and also control how the UTxO holding that token is spent, all under one identity. Many real Cardano designs are built this way, using a token as a mark that says "this UTxO is the real one", which only that same script could have created. From 5429f49894fe23249fbb870a1558d8875f7f23e5 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:22:39 -0300 Subject: [PATCH 66/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index b8f8fecbfb..2abb5c68b5 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -40,7 +40,7 @@ flowchart TD S["your validator,
compiled"] -->|hash it| H["one script hash"] H -->|written as an address| A["`**spend** guards the UTxOs locked there`"] - H -->|written as a policy ID| P["`**mint** + H -->|works as a policy ID| P["`**mint** guards tokens issued under it`"] H -->|registered as a stake credential| W["`**withdraw** guards reward withdrawals`"] From 109f0698a162e2822c77b751ea9e18fd6145d3f1 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:23:20 -0300 Subject: [PATCH 67/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index dd733738d5..a06f54c2c3 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -16,7 +16,7 @@ The last two lectures finished the list of what a validator is **given**: the ** A **parameter** is not on that list. It is a value built into the contract's own code, before the contract ever reaches the chain. Compiling leaves a **blank** where the value goes, and the contract is finished by filling that blank in. A parameter is baked **into** the validator, which is why you will never find it in `validator(datum, redeemer, context)`. -Of the values **you** supply, the useful way to tell them apart is **when the value is fixed**: +Of the values **you** supply, the useful way to tell them apart is **when the value is provided**: | | Fixed when | Lives in | To change it | |---|---|---|---| From 16755d973a51d3f3792fa1b3219c715cc1bd215f Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:23:30 -0300 Subject: [PATCH 68/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index a06f54c2c3..4ee61c5e15 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -18,7 +18,7 @@ A **parameter** is not on that list. It is a value built into the contract's own Of the values **you** supply, the useful way to tell them apart is **when the value is provided**: -| | Fixed when | Lives in | To change it | +| | Provided at | Lives in | To change it | |---|---|---|---| | **parameter** | build time | the contract itself | fill the blank differently: a new contract, at a **new address** | | **datum** | lock time | the locked UTxO | lock a new UTxO | From 69f5a479dda8b195c1db4576458e65e195c9be5b Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:25:17 -0300 Subject: [PATCH 69/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 4ee61c5e15..4cf11ab138 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -95,7 +95,7 @@ Every vault would share one address, and each locked UTxO would carry its own re Ask "is this the same for every UTxO at this address?" first. If yes, it is a parameter. Only if no do you go back to the datum or redeemer question. -There is one more thing you could do, and it is worse. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. +There is one more thing you could do. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault protocol needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter, you compile, test, and audit **once**, and each deployment only passes a different value in. :::warning A recovery key can spend the vault `Recover` is a real spending path, so whoever holds the recovery key can take the funds. That is the point of a backup, and it is also the risk. Use a key **you** control, such as a hardware wallet kept somewhere else. Never a key belonging to somebody you would not hand the funds to today. From 96f052194bd52e619bdd703b0c6da969d2aaf0bd Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:25:39 -0300 Subject: [PATCH 70/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 4cf11ab138..9eb3f19b55 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -113,7 +113,7 @@ The **datum** goes on the output when you lock. The **redeemer** goes in the spe Filling the blank does not compile anything and does not ask the network for anything. Your off-chain code takes the compiled script from your blueprint (`plutus.json`), with the blank still in it, supplies the missing value, and hashes what comes out. Two lines of ordinary code, and no transaction. **That is the whole of "deploying" a parameterized contract**, and you will write those two lines in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. -You will meet the word "deploy" in one other sense, though. It also describes putting the script into a UTxO, so that later transactions point at it instead of carrying a copy of it. That one really is a transaction, and it is optional: a way to make every spend smaller, not a step you must take before a contract works. **Reference inputs & scripts** does it. +You will meet the word "deploy" in one other sense, though. It also describes putting the script into a UTxO, so that later transactions point at it instead of carrying a copy of it. That one really is a transaction, and it is optional: a way to make every spend smaller, not a step you must take before a contract works. **Reference inputs & scripts** explains it. This contract's `VaultAction` finally lists two choices, so its redeemers finally use both constructor numbers from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: `Unlock` is constructor 0 and `Recover` is constructor 1. Get those two the wrong way round later and the vault will look at the wrong key, without complaining. From 3ea6fb80410d7c94d1772936dc1e17064cc273ad Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:26:48 -0300 Subject: [PATCH 71/89] Update docs/developers/onboarding/lectures/intermediate/7-parameters.md Co-authored-by: Robertino --- .../developers/onboarding/lectures/intermediate/7-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 9eb3f19b55..118406d825 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -144,7 +144,7 @@ Both branches ask `list.has`, so there is nothing new to import. aiken check ``` -**It does not compile**, and the error is the lesson. Your two tests from **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** call `vault.spend` with four arguments, and the handler now takes five. A parameter always comes **first**, before the handler's own arguments, so every call has to gain a `recovery` in front: +**It compiles and type-checks, but it doesn't pass the tests**. Your two tests from **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** call `vault.spend` with four arguments, and the handler now takes five. A parameter always comes **first**, before the handler's own arguments, so every call has to gain a `recovery` in front: ```aiken // was From 4cd791a14c977f5fd97291063a07146798a8eed5 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:28:00 -0300 Subject: [PATCH 72/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 2abb5c68b5..c4eabd3ec8 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -27,7 +27,7 @@ Every purpose works the same way. Something in a transaction touches your script The handler you write changes a little between purposes, because the question changes. A **spend** handler is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** handler is not, because nothing is being unlocked. Instead it is told which policy is being minted under. All of them receive the redeemer and the whole transaction. Your vault uses **spend** today. In this lecture it gains **mint** as well. -## One validator, many purposes, one hash +## One validator, one hash, many purposes A **single validator** can handle **several purposes at once**, and it has exactly **one hash**. That one hash is all of these at the same time: From e61a6a4521f5fee49507ea9a8dfc0a5739bdcf31 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:30:01 -0300 Subject: [PATCH 73/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index c4eabd3ec8..280dc2ad28 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -31,7 +31,7 @@ The handler you write changes a little between purposes, because the question ch A **single validator** can handle **several purposes at once**, and it has exactly **one hash**. That one hash is all of these at the same time: -- its **address** (for the _spend_ purpose), +- its **payment credential** (for the _spend_ purpose), - its **policy ID** (for the _mint_ purpose), - its **stake credential** (for the _withdraw_ purpose). From 23582975ad0cd80a9d2ce81a101cc267f2ed5b39 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:30:30 -0300 Subject: [PATCH 74/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 280dc2ad28..89e09d7094 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -38,7 +38,7 @@ A **single validator** can handle **several purposes at once**, and it has exact ```mermaid flowchart TD S["your validator,
compiled"] -->|hash it| H["one script hash"] - H -->|written as an address| A["`**spend** + H -->|works as payment credential (inside address)| A["`**spend** guards the UTxOs locked there`"] H -->|works as a policy ID| P["`**mint** guards tokens issued under it`"] From 2df7eb06a3d5f929f505b71eebd78eaab35e6c4d Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Mon, 31 Aug 2026 14:30:54 -0300 Subject: [PATCH 75/89] Update docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/8-validator-purposes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 89e09d7094..4cab016f72 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -42,7 +42,7 @@ flowchart TD guards the UTxOs locked there`"] H -->|works as a policy ID| P["`**mint** guards tokens issued under it`"] - H -->|registered as a stake credential| W["`**withdraw** + H -->|works as stake credential (inside address)| W["`**withdraw** guards reward withdrawals`"] ``` From 624325780b66780cc8c500bcc168a8dbec526741 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Tue, 1 Sep 2026 11:19:54 -0300 Subject: [PATCH 76/89] Rework the intermediate vault around a service admin key --- .../intermediate/4-datum-and-redeemer.md | 2 +- .../lectures/intermediate/7-parameters.md | 87 ++++++++----------- .../intermediate/8-validator-purposes.md | 42 ++++----- .../intermediate/9-frontend-integration.md | 10 +-- .../mesh/blueprints/vault.plutus.json | 19 ++-- .../vault/off-chain/mesh/src/app.tsx | 2 +- .../mesh/src/lib/{recover.ts => admin.ts} | 20 ++--- .../vault/off-chain/mesh/src/lib/blueprint.ts | 14 +-- .../vault/off-chain/mesh/src/lib/datum.ts | 8 +- .../vault/off-chain/mesh/src/lib/mint.ts | 4 +- .../vault/off-chain/mesh/src/main.tsx | 2 +- .../vault/off-chain/mesh/src/vault.test.ts | 44 +++++----- .../vault/on-chain/aiken/plutus.json | 58 +++++++++++-- .../on-chain/aiken/validators/guesser.ak | 21 +++++ .../vault/on-chain/aiken/validators/vault.ak | 60 +++++++------ .../on-chain/aiken/validators/vault_simple.ak | 12 ++- 16 files changed, 236 insertions(+), 169 deletions(-) rename examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/{recover.ts => admin.ts} (80%) create mode 100644 examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/guesser.ak diff --git a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md index d38615fc63..535272b34a 100644 --- a/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -108,7 +108,7 @@ Open `validators/vault.ak`, the file you wrote [last lecture](/docs/developers/o The shapes you are about to write need two things from the **[standard library](https://github.com/aiken-lang/stdlib)**: a type for the owner's key hash, and the types the handler is handed. The stdlib carries most of what a validator needs, from the ledger types you are importing here to helpers for lists, bytes and time intervals. Add both as the **first lines of the file**: - {extractRegion(VaultSimple, "datum-imports")} + {`${extractRegion(VaultSimple, "datum-imports")}\n${extractRegion(VaultSimple, "import-transaction")}`} Then write the datum and the redeemer themselves, **between the imports and the `validator` block**. These are the two shapes from the start of this lecture: diff --git a/docs/developers/onboarding/lectures/intermediate/7-parameters.md b/docs/developers/onboarding/lectures/intermediate/7-parameters.md index 5eac346f02..c6c81f5a6a 100644 --- a/docs/developers/onboarding/lectures/intermediate/7-parameters.md +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -9,6 +9,8 @@ import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import extractRegion from "@site/src/utils/extractRegion"; import VaultAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak"; +import GuesserAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/guesser.ak"; +import VaultSimpleAiken from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak"; # Parameters @@ -26,27 +28,15 @@ Of the values **you** supply, the useful way to tell them apart is **when the va The context is missing from that table on purpose. It is the transaction itself, settled by whoever builds the spend, rather than a value you choose and pass. -## Why the vault wants one - -Think about the vault so far. It releases funds to one owner, proven by one signature. Lose that key and the funds are gone for good, with nobody to ask for help. So we add a **backup key**: a second key, chosen when the vault is made, that can also take the funds out. The owner uses their key for normal spending. The backup is kept somewhere safe and is not touched until it is needed. - -Here are all three ways in, in one small contract: +## A small contract that uses a parameter -It is your vault with the backup key added, and the **[Try it](#try-it)** below makes exactly this change to the `vault.ak` you have been building: - - - {extractRegion(VaultAiken, "types")} - - - - {extractRegion(VaultAiken, "vault")} + + {extractRegion(GuesserAiken, "guesser")} -`list.has` is the same question your vault has been asking since **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, only asked twice now, about a different key each time. - @@ -55,54 +45,56 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -Read the three facts and where each one went: +`guess` is the parameter, in brackets after the contract's name. Whoever spends sends a number as the redeemer, and the funds move when the two numbers are equal. It is the first rule in this track that reads the redeemer at all. + +The parameter is fixed for the whole contract, so it is the same number for every UTxO at that address. Once somebody guesses it, they can take every UTxO sitting there. Setting a new number means filling the blank in again, and what comes out is a different contract at a different address. -- `recovery` is the **parameter**, in brackets after the contract's name. It is chosen once, when the contract is built, and it is the same for every UTxO this vault will ever hold. -- `owner` comes from the **datum**, attached when the funds were locked. Each locked UTxO can name a different owner. -- `Unlock` or `Recover` comes from the **redeemer**, chosen by whoever is spending, in that transaction. +Read this one, do not build it. The vault is the contract you write, in **[Try it](#try-it)** below. + +## Why the vault wants one -It asks the same _is this key among the signers?_ question your vault already asks, reading the signers straight off the transaction as the [last lecture](/docs/developers/onboarding/lectures/intermediate/transaction-context) described. Only what it is compared against changes: `Unlock` checks the signature against the **datum's** owner, `Recover` against the **parameter**. +The vault stops being one person's contract here and becomes a service. A company runs it, and every customer who locks funds gets a UTxO at the same address. The company holds one key of its own, the **admin key**, and that key can move the funds out of any of those UTxOs. Customers use their own key for their own funds, and the admin key is there for the day a customer loses theirs, the way a bank can reach an account when you forget the PIN. -The redeemer finally matters. Here it picks which rule applies, and each choice needs a different signature. +One key, for the whole service. It is chosen when the service is built, and it is the same for every customer, which is what makes it a parameter. ## Why a parameter changes the address -A parameter is part of the contract's code, so it changes the compiled bytes, which changes the **hash**. And the hash is the **address**. One piece of source, two recovery keys, two separate vaults: +A parameter is part of the contract's code, so it changes the compiled bytes, which changes the **hash**. And the hash is the **address**. One piece of source, two admin keys, two separate services: ```mermaid flowchart LR - S["the recovery vault
one source file"] -->|"compile
once"| C["`the contract with a **blank** - where recovery goes + S["the vault
one source file"] -->|"compile
once"| C["`the contract with a **blank** + where admin goes _no address yet_`"] - C -->|"fill it in:
recovery = aaaa…aa"| A["`one script hash + C -->|"fill it in:
admin = aaaa…aa"| A["`one script hash **addr_test1wrzptf…**`"] - C -->|"fill it in:
recovery = bbbb…bb"| B["`a different script hash + C -->|"fill it in:
admin = bbbb…bb"| B["`a different script hash **addr_test1wpc707…**`"] ``` The two addresses have nothing in common, and that is the reason to use a parameter at all. -Anyone can read the recovery key straight out of the contract. That is fine, because it is a public key **hash**, the same kind of value the datum holds. It names *who* may recover, and naming somebody is not the same as being them: taking the funds still needs a **signature** from that key, and only its owner can produce one. +Anyone can read the admin key straight out of the contract. That is fine, because it is a public key **hash**, the same kind of value the datum holds. It names *who* the admin is, and naming somebody is not the same as being them: taking the funds still needs a **signature** from that key, and only its owner can produce one. ## Why not the datum -Every vault would share one address, and each locked UTxO would carry its own recovery key, hidden inside until you opened it. Two UTxOs sitting side by side could have completely different backup keys and look identical. As a parameter, the key is part of the address, so a different key means a different address, and the address alone tells you which key can recover. +Every service would share one address, and each locked UTxO would carry its own copy of the admin key, hidden inside until you opened it. Nothing would stop one customer locking a UTxO that names themselves as admin, and it would look identical to every other UTxO at that address. As a parameter, the key is part of the address, so one address means one admin, and reading the address is enough to know who it is. **[Datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)** left you a rule for choosing between the datum and the redeemer. A parameter sits above both of them, and the question it answers is different: -- **Parameter** for settings fixed when the contract is deployed, the same for every UTxO at that address: a recovery key, an oracle's address, a token policy. +- **Parameter** for settings fixed when the contract is deployed, the same for every UTxO at that address: an admin key, an oracle's address, a token policy. - **Datum** for facts that differ from one locked UTxO to the next. Ask "is this the same for every UTxO at this address?" first. If yes, it is a parameter. Only if no do you go back to the datum or redeemer question. -There is one more thing you could do, and it is worse. You could simply **write the recovery key into the code**. It would be just as fixed and just as safe. But then every new vault needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. +There is one more thing you could do, and it is worse. You could simply **write the admin key into the code**. It would be just as fixed and just as safe. But then every new deployment needs a change to the contract itself, which means compiling it again, testing it again, and having it audited again. With a parameter you compile and test **once**, and each deployment only passes a different value in. -:::warning A recovery key can spend the vault -`Recover` is a real spending path, so whoever holds the recovery key can take the funds. That is the point of a backup, and it is also the risk. Use a key **you** control, such as a hardware wallet kept somewhere else. Never a key belonging to somebody you would not hand the funds to today. +:::warning An admin key can spend anybody's funds +`AdminUnlock` is a real spending path, so the company holding the admin key can take any customer's funds. This is custody: the funds are only as safe as that one key and the company behind it. ::: -:::note The same shape is how contracts get an admin -`Recover` is one instance of a wider pattern: a named key with a path of its own, for the cases the main rule cannot cover. Contracts use it for a project key that alone may mint a collection's NFTs, for the single key allowed to update a price feed, which is the oracle you build in **modifying state**, and for an admin who can pause a protocol by updating a config UTxO that every other validator reads as a **reference input**. +:::note Where else a named key gets a path of its own +A project key that alone may mint a collection's NFTs, the single key allowed to update a price feed, which is the oracle you build in **modifying state**, and a key that can pause a protocol by updating a config UTxO every other validator reads as a **reference input**. Where that key is named follows the rule above: a parameter when it is fixed for the whole deployment, the datum when it differs from one UTxO to the next, as the oracle's does. ::: @@ -115,24 +107,22 @@ Filling the blank does not compile anything and does not ask the network for any You will meet the word "deploy" in one other sense, though. It also describes putting the script into a UTxO, so that later transactions point at it instead of carrying a copy of it. That one really is a transaction, and it is optional: a way to make every spend smaller, not a step you must take before a contract works. **Reference inputs & scripts** does it. -This contract's `VaultAction` finally lists two choices, so its redeemers finally use both constructor numbers from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**: `Unlock` is constructor 0 and `Recover` is constructor 1. Get those two the wrong way round later and the vault will look at the wrong key, without complaining. - ## Try it -**Give your vault a backup key.** It is the contract you already have plus one parameter and one extra action. +**Give your vault an admin key.** It is the contract you already have plus one parameter and one extra action. Everything below runs from `on-chain/vault/`, where lecture 2 left you. -Open `validators/vault.ak`. The redeemer changes first: `VaultAction` gains `Recover`, on the line after `Unlock`. The order matters, because it is what makes `Recover` constructor 1 off-chain, the `mConStr1([])` from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**. `VaultDatum` does not move at all, the owner is still the one fact each locked UTxO carries. +Open `validators/vault.ak`. The redeemer changes first: `VaultAction` gains `AdminUnlock`, on the line after `Unlock`. The order matters: an action reaches the validator as a number, and that number is its position in this list, so swapping the two lines swaps which key the vault checks. You write the off-chain side of that in **[frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)**. `VaultDatum` does not move at all, the owner is still the one fact each locked UTxO carries. {extractRegion(VaultAiken, "types")} -Then the validator itself. It takes the parameter in brackets after its name, `_redeemer` loses its underscore because the rule finally reads it, and the single line you wrote last lecture becomes a `when` with one branch per action. Both branches ask the same question: is this key among the signers? And differ only in which key they ask about: the datum's `owner` for `Unlock`, the parameter's `recovery` for `Recover`. +Then the validator itself. It takes the parameter in brackets after its name, `_redeemer` loses its underscore because the rule finally reads it, and the single line you wrote last lecture becomes a `when` with one branch per action. Both branches ask the same question: is this key among the signers? And differ only in which key they ask about: the datum's `owner` for `Unlock`, the parameter's `admin` for `AdminUnlock`. Both branches ask `list.has`, so there is nothing new to import. @@ -144,26 +134,23 @@ Both branches ask `list.has`, so there is nothing new to import. aiken check ``` -**It does not compile**, and the error is the lesson. Your two tests from **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** call `vault.spend` with four arguments, and the handler now takes five. A parameter always comes **first**, before the handler's own arguments, so every call has to gain a `recovery` in front: +**It does not compile**, and the error is the lesson. Your two tests from **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** call `vault.spend` with four arguments, and the handler now takes five. A parameter always comes **first**, before the handler's own arguments, so every call has to gain an `admin` in front: -```aiken -// was -vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) -// now -vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) -``` + + {`// was\n${extractRegion(VaultSimpleAiken, "spend-call")}\n// now\n${extractRegion(VaultAiken, "spend-call")}`} + -Add the recovery key beside `owner` and `stranger`, and a test for each side of the new door: +Add the admin key beside `owner` and `stranger`, and a test for each side of the new door: - {extractRegion(VaultAiken, "recover-tests")} + {extractRegion(VaultAiken, "admin-tests")} Fix the three existing calls the same way, then run `aiken check` again. Five tests, five passes. -`recover_ok_when_the_recovery_key_signs` is the obvious one of the two. **`recover_fails_when_the_owner_signs` is the one that matters**: it asks whether the two doors are genuinely separate. A vault where the owner can also take the `Recover` path compiles exactly as happily as one where they cannot, and nothing but that test tells the two apart. +`admin_unlock_ok_when_the_admin_signs` is the obvious one of the two. **`admin_unlock_fails_when_the_owner_signs` is the one that matters**: it asks whether the two doors are genuinely separate. A vault where the owner can also take the `AdminUnlock` path compiles exactly as happily as one where they cannot, and nothing but that test tells the two apart. -Your vault now has two ways in: the owner's key for normal use, and the backup key for the day it is needed. +Your vault now has two ways in: each customer's key for their own funds, and the company's admin key for all of them. diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 9587258f31..b244cf13e4 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -25,7 +25,7 @@ These are the purposes you will meet: Every purpose works the same way. Something in a transaction touches your script, the network runs your validator, and it answers **yes or no**. Only the trigger and the thing being guarded change. You have already seen a simpler version of the mint purpose. The Beginner [minting example](/docs/developers/onboarding/lectures/beginner/tokens-fungible-and-nfts) used a **native script** as its policy. You use a validator instead when the rule needs to do more than check who signs and when. -The handler you write changes a little between purposes, because the question changes. A **spend** handler is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** handler is not, because nothing is being unlocked. Instead it is told which policy is being minted under. All of them receive the redeemer and the whole transaction. Your vault uses **spend** today. In this lecture it gains **mint** as well. +The purpose changes a little how you write the validator. A **spend** validator is given the **datum**, because there is a locked UTxO with a note attached to it. A **mint** validator is not, because nothing is being unlocked. Every purpose is given the redeemer and the transaction context. Your vault uses **spend** today. In this lecture it gains **mint** as well. ## One validator, many purposes, one hash @@ -52,7 +52,7 @@ Because the script sees its own hash in more than one role, it can **connect** t ## Your vault declares only one purpose, so far -The vault you have been building handles only **spend**. Its source says so in two places: the spend handler you wrote, and the `else` block that **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** asked you to copy without explaining: +The vault you have been building handles only **spend**. Its source says so in two places: the spend validator you wrote, and the `else` block that **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator)** asked you to copy without explaining: @@ -71,17 +71,17 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o -That is what it has been doing all along: covering **every other purpose**. If anything tries to use this script as a minting policy, or a stake credential, or anything else besides spending, the answer is no. Writing only a spend handler is not the same as making spending the only thing possible. You will see this pair, one real handler plus a refusing `else`, in most small contracts. +That is what it has been doing all along: covering **every other purpose**. If anything tries to use this script as a minting policy, or a stake credential, or anything else besides spending, the answer is no. Writing only a spend validator is not the same as making spending the only thing possible. You will see this pair, one real purpose plus a refusing `else`, in most small contracts. -## Giving the vault a token of its own +## A validator whose purpose is minting -One script *can* carry every purpose at once. Whether it *should* is a design decision, and the vault is about to make the other one. +Every token on Cardano is identified by two values: a **policy id** and a token name. The policy is the script that decides whether tokens under it may be created or destroyed, and the policy id is that script's hash. Two tokens sharing a name under different policy ids are different tokens. Rewriting the policy changes its hash, which makes it a different policy guarding a different token. -The token gets a script to itself: a validator whose only handler is `mint`. Its hash is the token's policy id, and it has nothing to do with the vault's address. +The vault's `else` refuses every purpose that is not spend, so a token needs a validator of its own (one script *can* carry every purpose at once, but in this lecture we write two validators). That validator has one purpose, `mint`, and its hash is the token's policy id. It has nothing to do with the vault's address. -Splitting them costs a little. The rules stay small, each one answering about the thing it guards. And the hashes stop moving together, which matters here because **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)** just put a blank in the vault. +This token is an example, not part of the vault. Nothing in the vault's validator mentions it, and nothing in the policy mentions the vault. -Neither script minds. A transaction can run both, so one transaction still creates the token and locks it in the vault at once. +The two never run together. Minting runs the policy, and that transaction can send the new token straight to the vault's address. Spending that UTxO later runs the vault, and the policy stays out of it. ```mermaid flowchart LR @@ -89,18 +89,18 @@ flowchart LR subgraph T["one transaction"] direction TB - M["mint 1 VAULT
the policy runs"] --> L["build one output at
the vault's address"] + M["mint 1 TOKEN A
the policy runs"] --> L["build one output at
the vault's address"] end - T --> V["UTxO at the vault
5 ADA + 1 VAULT + datum"] - V -->|"later: unlock
the vault's spend handler runs"| B["your wallet
5 ADA + 1 VAULT"] + T --> V["UTxO at the vault
5 ADA + 1 TOKEN A + datum"] + V -->|"later: unlock
the vault's spend validator runs"| B["your wallet
5 ADA + 1 TOKEN A"] ``` It reaches you when you unlock, together with the ADA it was guarding. ## Try it -**Give your vault a token of its own.** The vault's `else` block goes on refusing everything but spending, and the token gets a validator of its own beside it. +**Write a minting policy.** A second validator, beside the vault. @@ -127,9 +127,9 @@ Now the rule for the token itself. Write it as a **second validator**, below the Read the arguments, because they differ from `spend`. **No datum reaches this handler**: a datum belongs to the UTxO being unlocked, and minting unlocks nothing. The transaction can still attach a datum to an output it creates, and the one that mints a token and locks it does, but that note belongs to the new vault UTxO and the mint rule is never handed it. Instead the handler is told its own `policy_id`, which is this script's hash. -`self.mint` holds everything the transaction creates or destroys, under every policy. `assets.tokens` gives back only the tokens minted under this one, as a dictionary of token name to amount, and `dict.to_pairs` turns that into a list. Matching the list against `[Pair(name, _)]` succeeds only if it holds exactly one entry, so the transaction cannot mint a second name under this policy. `name == vault_token` then decides which name that has to be. +`self.mint` holds everything the transaction creates or destroys, under every policy. `assets.tokens` gives back only the tokens minted under this one, as a dictionary of token name to amount, and `dict.to_pairs` turns that into a list. Matching the list against `[Pair(name, _)]` succeeds only if it holds exactly one entry, so the transaction cannot mint a second name under this policy. `name == token_name` then decides which name that has to be. -**Notice which script this is.** The vault takes `recovery` as a parameter and this policy takes none, so the two hashes move independently. Change your backup key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy id stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. +**Notice which script this is.** The vault takes `admin` as a parameter and this policy takes none, so the two hashes move independently. Change your admin key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy id stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. ```bash aiken check @@ -141,7 +141,7 @@ Open `plutus.json` and look at the `validators` list. It now has **four** entrie Compare the policy's with ours: ``` -736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c +32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0 ``` This one you should match exactly: there is no blank to fill, so nothing about your setup can move it. @@ -152,7 +152,7 @@ The vault's is the other kind: 5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4 ``` -If that matches, you wrote the same spend rule we did, byte for byte. Your vault takes a parameter, so this is the script with the blank still in it, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. Filling the blank with a real recovery key gives a different hash, and that one is the address funds actually go to. +If that matches, you wrote the same spend rule we did, byte for byte. Your vault takes a parameter, so this is the script with the blank still in it, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. Filling the blank with a real admin key gives a different hash, and that one is the address funds actually go to. @@ -168,7 +168,7 @@ The hash is made from the **compiled code**, not from what the contract does. Tw So the two things answer different questions. Your **tests** say the vault behaves correctly. The **hash** says you wrote it the same way we did. If yours passes the tests but misses the hash, nothing is wrong: it works, and it simply lives at a different address than ours. Only worry if the tests fail. ::: -**And unlocking needs no change at all.** The vault's spend handler still checks the owner's signature, exactly as it did before there was a token. +**And unlocking needs no change at all.** The vault's spend validator still checks the owner's signature, exactly as it did before there was a token. The purpose the network runs follows from what the transaction does: @@ -178,7 +178,7 @@ The purpose the network runs follows from what the transaction does: Any one script can be asked all three questions. Yours answers two of them across two scripts, which is the more common shape once a contract grows. -**Now prove the new validator.** A `mint` handler is a new rule, so it needs its own tests, and they are written exactly like the ones you already have. +**Now prove the new validator.** A `mint` validator is new, so it needs its own tests, and they are written exactly like the ones you already have. @@ -197,7 +197,7 @@ aiken check Eight tests, eight passes. Five of them are the spend rule from the last two lectures, still green, which is the other thing a test suite is for: you just added a whole new script beside the vault and you know for certain you did not disturb it. -**Then break the new rule.** Change `"VAULT"` in the constant to `"IMPOSTOR"` and run `aiken check` again. `mint_ok_for_a_correctly_named_token` and `mint_fails_for_a_wrongly_named_token` both go red together: one says the allowed case is now refused, the other says the forbidden case is now allowed. Put the name back. +**Then break the new rule.** Change `"TOKEN A"` in the constant to `"IMPOSTOR"` and run `aiken check` again. `mint_ok_for_a_correctly_named_token` and `mint_fails_for_a_wrongly_named_token` both go red together: one says the allowed case is now refused, the other says the forbidden case is now allowed. Put the name back. @@ -211,13 +211,13 @@ A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, o That is the vault: a spend rule with two doors, a mint policy guarding its own token, and eight tests saying so. -Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the whole off-chain side in one go: the address, the transactions that lock, unlock, recover and mint, the tests that drive them, and a page in a browser with buttons on it. +Nothing after this changes it. **[Frontend integration](/docs/developers/onboarding/lectures/intermediate/frontend-integration)** is the whole off-chain side in one go: the address, the transactions that lock, unlock, mint and spend as the admin, the tests that drive them, and a page in a browser with buttons on it. Stuck? The finished code is in the playground. See the **[introduction](/docs/developers/onboarding/lectures/intermediate/introduction#the-playground)**. ## Go deeper -- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator): "one validator, many purposes, one hash," with real handlers. +- [Write a Validator](/docs/developers/curriculum/smart-contracts/write-a-validator): "one validator, many purposes, one hash," with real validators. - [Smart Contracts (overview)](/docs/developers/curriculum/smart-contracts/overview): the full purpose table. - [Minting policies](/docs/developers/curriculum/native-tokens/minting-policies): the mint purpose in depth, native and script policies side by side. - [Staking](/docs/developers/curriculum/staking-governance/staking): where stake credentials and the withdraw purpose fit in. diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index a6d48bc007..e0363ac329 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -45,7 +45,7 @@ Locking is an ordinary payment that happens to be addressed to a script, with th Your wallet signing a transaction is not the same as your key hash appearing in the transaction's required-signers field. That field is `extra_signatories`, the one your vault reads in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, and asking for it is a separate step from signing. Forget it and the signature is there but the validator cannot see it, so a correct contract refuses a legitimate spend. -Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy id, the identifier saying which script may create a token. That is **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** in practice. Minting does add collateral, because it runs a script, and a plain lock does not. +Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy id, from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. Minting does add collateral, because it runs a script, and a plain lock does not. ## Collateral, and what a script costs @@ -168,9 +168,9 @@ Four things in it: - **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves of your workspace touch, and it is a file, not a network call. - **The title** `vault.vault.spend` is `..`, so it names your `vault.ak`, its `vault` validator, and its spend handler. - **`applyParamsToScript`** fills the blank from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. These are the two lines that lecture promised you. -- **`RECOVERY`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out. +- **`ADMIN`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out. -:::caution Changing RECOVERY moves the vault +:::caution Changing ADMIN moves the vault It is part of the script, so it is part of the hash, so it is part of the address. Lock funds with one value, change a single character, and your app will look for them somewhere else entirely and find nothing. The funds are not lost, they are at the old address, but you would have to put the old value back to reach them. ::: @@ -182,7 +182,7 @@ The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/interm {extractRegion(Datum, "file")} -`mConStr0` is the numbered-constructor encoding that lecture described. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `Recover`, because it is declared second in `VaultAction`. +`mConStr0` names a constructor by number, which is how a type reaches a validator: by the position it was declared in, not by its name. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `AdminUnlock`, because it is declared second in `VaultAction`. ### 4. The four transactions @@ -409,7 +409,7 @@ Open the printed URL **in the browser where Lace is installed**, with Lace set t 2. **Lock 5 ADA.** Approve it. This is the plain payment: no contract runs. 3. **Refresh locked** after a few seconds, and your UTxO appears. 4. **Unlock.** This one runs your validator. The funds come back. -5. **Mint & lock 5 ADA.** The same lock, plus a VAULT token created under the contract's own policy, in one transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. +5. **Mint & lock 5 ADA.** The same lock, plus a TOKEN A minted under the policy you wrote, in one transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. If the page loads but **Lock** fails, look at `.env` before anything else. A Preview key starts with `preview`, and a mainnet or mistyped key shows up as a 401 on `/api/blockfrost/…` in the browser's **Network** tab. diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json index d4fd696739..c12a6dadc3 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json @@ -27,7 +27,7 @@ }, "parameters": [ { - "title": "recovery", + "title": "admin", "schema": { "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" } @@ -43,7 +43,7 @@ }, "parameters": [ { - "title": "recovery", + "title": "admin", "schema": { "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" } @@ -60,16 +60,16 @@ "$ref": "#/definitions/Data" } }, - "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", - "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" }, { "title": "vault.vault_policy.else", "redeemer": { "schema": {} }, - "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", - "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" }, { "title": "vault_simple.vault.spend", @@ -102,6 +102,9 @@ "title": "Data", "description": "Any Plutus data." }, + "Int": { + "dataType": "integer" + }, "aiken/crypto/VerificationKeyHash": { "title": "VerificationKeyHash", "dataType": "bytes" @@ -116,7 +119,7 @@ "fields": [] }, { - "title": "Recover", + "title": "AdminUnlock", "dataType": "constructor", "index": 1, "fields": [] @@ -167,4 +170,4 @@ ] } } -} \ No newline at end of file +} diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx index cf23ef2d70..5bb2207ca9 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -1,6 +1,6 @@ // Playground bookkeeping, kept out of the lecture: this file is what lecture 9 // renders, and it runs here too, served at **/vault.html**. `main.tsx` is the -// same idea with the recovery door and some styling. Both drive the same +// same idea with the admin door and some styling. Both drive the same // `./lib`, which is the code the reader writes. // #region file /// The page: connect a wallet, lock 5 ADA, mint the vault's own token, and diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/admin.ts similarity index 80% rename from examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts rename to examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/admin.ts index 56632e8a98..6f08c53fc0 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/recover.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/admin.ts @@ -3,11 +3,11 @@ import { MeshTxBuilder, deserializeAddress } from "@meshsdk/core"; import type { IEvaluator, IFetcher, IWallet, UTxO } from "@meshsdk/core"; import { vaultScriptCbor } from "./blueprint.ts"; -import { recoverRedeemer } from "./datum.ts"; +import { adminRedeemer } from "./datum.ts"; /// `unlock.ts` with one line changed. See that file for what each builder call /// does; the difference is marked below. -export async function buildRecoverTx( +export async function buildAdminUnlockTx( wallet: IWallet, provider: IFetcher, lockedUtxo: UTxO, @@ -15,9 +15,9 @@ export async function buildRecoverTx( ): Promise { // Where the wallet wants anything left over sent back to. const changeAddress = await wallet.getChangeAddress(); - // The *backup* wallet's key hash. Whichever wallet you hand in is the one + // The *admin* wallet's key hash. Whichever wallet you hand in is the one // whose signature this asks for. - const recovery = deserializeAddress(changeAddress).pubKeyHash; + const admin = deserializeAddress(changeAddress).pubKeyHash; // The deposit, the same as any other spend that runs a script. const collateral = (await wallet.getCollateral())[0]; if (!collateral) { @@ -43,13 +43,13 @@ export async function buildRecoverTx( .txInScript(vaultScriptCbor) // The datum is already on the UTxO, so there is nothing to attach here. .txInInlineDatumPresent() - // #region recover-redeemer - // **The one line that differs from `unlock.ts`**: `Recover`, which tells the + // #region admin-redeemer + // **The one line that differs from `unlock.ts`**: `AdminUnlock`, which tells the // validator to check the key built into the script instead of the owner. - .txInRedeemerValue(recoverRedeemer) - // And so the signature it looks for is the recovery key's. - .requiredSignerHash(recovery) - // #endregion recover-redeemer + .txInRedeemerValue(adminRedeemer) + // And so the signature it looks for is the admin key's. + .requiredSignerHash(admin) + // #endregion admin-redeemer // Offer the deposit found above. .txInCollateral( collateral.input.txHash, diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts index 8d9218e6f5..d67eee828f 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts @@ -11,11 +11,11 @@ import blueprint from "../../blueprints/vault.plutus.json" with { type: "json" } export { blueprint }; -// #region recovery-const -// The recovery key this vault is compiled around. It fixes the address, so it -// has to stay the same forever. -const RECOVERY = "00000000000000000000000000000000000000000000000000000000"; -// #endregion recovery-const +// #region admin-const +// The admin key this vault is compiled around. It fixes the address, so it has +// to stay the same forever. +const ADMIN = "00000000000000000000000000000000000000000000000000000000"; +// #endregion admin-const const PLUTUS_VERSION = "V3"; @@ -27,9 +27,9 @@ function compiledCode(source: Blueprint, title: string): string { return validator.compiledCode; } -/// The compiled contract, with the recovery key built into it. +/// The compiled contract, with the admin key built into it. // #region params -export const vaultScriptCbor = applyParamsToScript(compiledCode(blueprint, "vault.vault.spend"), [RECOVERY]); +export const vaultScriptCbor = applyParamsToScript(compiledCode(blueprint, "vault.vault.spend"), [ADMIN]); // #endregion params /// The token's policy, a second script. It takes no parameter, so the list of diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts index 8bdbea0b36..50998560a2 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts @@ -10,8 +10,8 @@ export function vaultDatum(ownerPubKeyHash: string): Data { /// The redeemer for `Unlock`. export const unlockRedeemer: Data = mConStr0([]); -// #region recover -/// The redeemer for `Recover`, added in parameters. -export const recoverRedeemer: Data = mConStr1([]); -// #endregion recover +// #region admin +/// The redeemer for `AdminUnlock`, added in parameters. +export const adminRedeemer: Data = mConStr1([]); +// #endregion admin // #endregion file diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts index aade2caf5f..1b2e0fd202 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts @@ -6,7 +6,7 @@ import { vaultAddress, vaultTokenPolicyId, vaultTokenScriptCbor } from "./bluepr import { vaultDatum } from "./datum.ts"; /// The token name the policy allows, as the contract spells it. -export const VAULT_TOKEN_NAME = "VAULT"; +export const TOKEN_NAME = "TOKEN A"; /// Build a transaction that **mints one vault token and locks it**, together with /// `lovelace`, at the vault's address. @@ -34,7 +34,7 @@ export async function buildMintAndLockTx( // this is a different value. const policyId = vaultTokenPolicyId(); // Token names travel as hex on the chain, so convert it once here. - const tokenNameHex = stringToHex(VAULT_TOKEN_NAME); + const tokenNameHex = stringToHex(TOKEN_NAME); const txBuilder = new MeshTxBuilder({ fetcher: provider }); return await txBuilder diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx index b829068d16..b26ded8975 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx @@ -152,7 +152,7 @@ function App() { Mint & lock 5 ADA

- The second button also mints one VAULT token and locks it with the ADA. Two scripts + The second button also mints one TOKEN A token and locks it with the ADA. Two scripts run in that one transaction: the policy decides the token may exist, and the vault's address is where it lands. Unlocking brings both back.

diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts index 4e01434203..c9a7b2ba4d 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts @@ -23,7 +23,7 @@ import { buildLockTx } from "./lib/lock.ts"; import { buildUnlockTx } from "./lib/unlock.ts"; // #endregion offline-imports -// The other tests in this file need more: the mint and recover builders, and two +// The other tests in this file need more: the mint and admin builders, and two // helpers for applying a parameter by hand. The blueprint comes from // `lib/blueprint.ts`, which is where its path is written down once. import { applyParamsToScript, serializePlutusScript } from "@meshsdk/core"; @@ -31,7 +31,7 @@ import { applyParamsToScript, serializePlutusScript } from "@meshsdk/core"; import { blueprint } from "./lib/blueprint.ts"; import { buildMintAndLockTx } from "./lib/mint.ts"; -import { buildRecoverTx } from "./lib/recover.ts"; +import { buildAdminUnlockTx } from "./lib/admin.ts"; // An in-memory chain and a funded wallet. No node, no network, no test ADA, and // no waiting: every test below builds a real transaction and runs the real @@ -96,13 +96,13 @@ function fund(fetcher: OfflineFetcher, address: string) { const FIVE_ADA: Asset[] = [{ unit: "lovelace", quantity: "5000000" }]; // #endregion offline-helpers -/// The vault built around an arbitrary recovery key, rather than the one fixed +/// The vault built around an arbitrary admin key, rather than the one fixed /// in `lib/blueprint.ts`. The reader changes that constant by hand; this lets /// the parameters test show two keys giving two addresses in a single run. -function recoveryVaultAddress(recoveryPubKeyHash: string, networkId: number): string { +function adminVaultAddress(adminPubKeyHash: string, networkId: number): string { const validator = blueprint.validators.find((v) => v.title === "vault.vault.spend"); if (!validator) throw new Error('validator "vault.vault.spend" not found in the blueprint'); - const cbor = applyParamsToScript(validator.compiledCode, [recoveryPubKeyHash]); + const cbor = applyParamsToScript(validator.compiledCode, [adminPubKeyHash]); return serializePlutusScript({ code: cbor, version: "V3" }, undefined, networkId).address; } @@ -118,7 +118,7 @@ test("lock: the vault's lock transaction is an ordinary payment carrying a datum }); // #endregion offline-lock -test("mint: one transaction mints a vault token and locks it", async () => { +test("mint: one transaction mints the token and locks it", async () => { const fetcher = newFetcher(); const owner = await makeWallet(fetcher, OWNER); const address = await owner.getChangeAddress(); @@ -159,13 +159,13 @@ test("unlock: the vault releases funds to the owner who signs", async () => { }); // #endregion offline-unlock -// The recovery door, proven offline. `lib/blueprint.ts` compiles the vault -// around a fixed `RECOVERY` constant, and no wallet's key hash is ever going to -// equal it, so what this can show is the half that matters: `Recover` checks the -// key welded into the script and ignores the datum's owner entirely. The owner -// signing a `Recover` spend is refused, which is exactly what keeps the two +// The admin door, proven offline. `lib/blueprint.ts` compiles the vault around +// a fixed `ADMIN` constant, and no wallet's key hash is ever going to equal it, +// so what this can show is the half that matters: `AdminUnlock` checks the key +// welded into the script and ignores the datum's owner entirely. The owner +// signing an `AdminUnlock` spend is refused, which is exactly what keeps the two // doors separate. -test("recover: the owner's signature does not open the recovery door", async () => { +test("admin: the owner's signature does not open the admin door", async () => { const fetcher = newFetcher(); const owner = await makeWallet(fetcher, OWNER); const address = await owner.getChangeAddress(); @@ -181,27 +181,27 @@ test("recover: the owner's signature does not open the recovery door", async () // The same UTxO the unlock test spends, and the same wallet signing it. Only // the redeemer differs, so only the branch the validator takes differs. - const unsignedTx = await buildRecoverTx(owner, fetcher, locked); + const unsignedTx = await buildAdminUnlockTx(owner, fetcher, locked); const evaluator = new OfflineEvaluator(fetcher, "preview"); await assert.rejects( () => evaluator.evaluateTx(unsignedTx, [], []), - "the validator should refuse a Recover signed by the owner", + "the validator should refuse an AdminUnlock signed by the owner", ); }); -test("parameters: a different recovery key gives the vault a different address", () => { +test("parameters: a different admin key gives the vault a different address", () => { // Two 28-byte key hashes, written as hex. const alice = "a".repeat(56); const bob = "b".repeat(56); - const alicesVault = recoveryVaultAddress(alice, NETWORK); - const bobsVault = recoveryVaultAddress(bob, NETWORK); + const alicesVault = adminVaultAddress(alice, NETWORK); + const bobsVault = adminVaultAddress(bob, NETWORK); - // Same source code, same compiled validator, two addresses. The recovery key - // is part of the script, the script's hash is the address, so changing the - // key moves the vault. - assert.notEqual(alicesVault, bobsVault, "each recovery key should get its own address"); + // Same source code, same compiled validator, two addresses. The admin key is + // part of the script, the script's hash is the address, so changing the key + // moves the vault. + assert.notEqual(alicesVault, bobsVault, "each admin key should get its own address"); // Both are real addresses, which also proves the parameter was applied to a // script the ledger can read rather than producing nonsense. @@ -210,5 +210,5 @@ test("parameters: a different recovery key gives the vault a different address", } // And it is stable: the same key always lands on the same vault. - assert.equal(recoveryVaultAddress(alice, NETWORK), alicesVault); + assert.equal(adminVaultAddress(alice, NETWORK), alicesVault); }); diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json index d4fd696739..bbb386e105 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json @@ -11,6 +11,47 @@ "license": "Apache-2.0" }, "validators": [ + { + "title": "guesser.guesser.spend", + "datum": { + "title": "_datum", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/Int" + } + }, + "parameters": [ + { + "title": "guess", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "5862010100229800aba2aba1aab9eaab9dab9a9bad00248888896600264646644b30013370e900118039baa002899199119b87375a601800c01060140026014601600260106ea800a2c8030c01cc020004c01c008c01c004c010dd5003c52689b2b20041", + "hash": "ddf2d5fbe101c5dcd3ef5105b71b0cee4f24498a8b446f1b4a9666bd" + }, + { + "title": "guesser.guesser.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "guess", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "5862010100229800aba2aba1aab9eaab9dab9a9bad00248888896600264646644b30013370e900118039baa002899199119b87375a601800c01060140026014601600260106ea800a2c8030c01cc020004c01c008c01c004c010dd5003c52689b2b20041", + "hash": "ddf2d5fbe101c5dcd3ef5105b71b0cee4f24498a8b446f1b4a9666bd" + }, { "title": "vault.vault.spend", "datum": { @@ -27,7 +68,7 @@ }, "parameters": [ { - "title": "recovery", + "title": "admin", "schema": { "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" } @@ -43,7 +84,7 @@ }, "parameters": [ { - "title": "recovery", + "title": "admin", "schema": { "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" } @@ -60,16 +101,16 @@ "$ref": "#/definitions/Data" } }, - "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", - "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" }, { "title": "vault.vault_policy.else", "redeemer": { "schema": {} }, - "compiledCode": "58c501010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d221055641554c5400375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", - "hash": "736feeda8f96f7bb3d291839666a01c51a1de073ab25c5d7f6056b6c" + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" }, { "title": "vault_simple.vault.spend", @@ -102,6 +143,9 @@ "title": "Data", "description": "Any Plutus data." }, + "Int": { + "dataType": "integer" + }, "aiken/crypto/VerificationKeyHash": { "title": "VerificationKeyHash", "dataType": "bytes" @@ -116,7 +160,7 @@ "fields": [] }, { - "title": "Recover", + "title": "AdminUnlock", "dataType": "constructor", "index": 1, "fields": [] diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/guesser.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/guesser.ak new file mode 100644 index 0000000000..384813209d --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/guesser.ak @@ -0,0 +1,21 @@ +use cardano/transaction.{OutputReference, Transaction} + +// The smallest contract that needs a parameter. +// `guess` is welded in at build time, so every UTxO at this address opens with +// the same number. +// #region guesser +validator guesser(guess: Int) { + spend( + _datum: Option, + redeemer: Int, + _own_ref: OutputReference, + _self: Transaction, + ) { + redeemer == guess + } + + else(_) { + fail + } +} +// #endregion guesser diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak index 1fbe668109..dc855be0ee 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak @@ -24,22 +24,24 @@ pub type VaultDatum { // #region types pub type VaultAction { Unlock - Recover + AdminUnlock } // #endregion types // The name of the token this policy can mint. One policy, one token name. +// The token is an example, not part of the vault: neither script mentions the +// other. // #region token-name -const vault_token: ByteArray = "VAULT" +const token_name: ByteArray = "TOKEN A" // #endregion token-name // The vault the reader builds across the track: the owner's key for normal use, -// and a backup key welded in at build time. +// and the service's admin key welded in at build time. // -// Three facts reach `spend`, each fixed at a different moment: `recovery` at +// Three facts reach `spend`, each fixed at a different moment: `admin` at // build time, `owner` at lock time, and the action at spend time. // #region vault -validator vault(recovery: VerificationKeyHash) { +validator vault(admin: VerificationKeyHash) { spend( datum: Option, redeemer: VaultAction, @@ -49,7 +51,7 @@ validator vault(recovery: VerificationKeyHash) { expect Some(VaultDatum { owner }) = datum when redeemer is { Unlock -> list.has(self.extra_signatories, owner) - Recover -> list.has(self.extra_signatories, recovery) + AdminUnlock -> list.has(self.extra_signatories, admin) } } @@ -60,7 +62,7 @@ validator vault(recovery: VerificationKeyHash) { // #endregion vault // A second script, with no parameter, so its hash is the same for everybody -// while the vault's moves with the recovery key. That hash is also this script's +// while the vault's moves with the admin key. That hash is also this script's // policy id, so it decides which tokens may exist under it: one token in, or one // token out. // #region mint-validator @@ -68,7 +70,7 @@ validator vault_policy { mint(_redeemer: Data, policy_id: PolicyId, self: Transaction) { expect [Pair(name, _)] = assets.tokens(self.mint, policy_id) |> dict.to_pairs() - name == vault_token + name == token_name } else(_) { @@ -78,11 +80,11 @@ validator vault_policy { // #endregion mint-validator // Tests. The reader starts them in **testing**, against the vault as it stands -// there, and extends them as the contract grows: the recovery pair in +// there, and extends them as the contract grows: the admin pair in // **parameters**, the mint trio in **validator purposes**. // // Each one copies `transaction.placeholder` and fills in only the field its rule -// reads. `recovery` leads every call into `vault`, the way a parameter always +// reads. `admin` leads every call into `vault`, the way a parameter always // comes first in a handler, and none into `vault_policy`, which has no parameter. // #region spend-tests @@ -94,12 +96,12 @@ const stranger: VerificationKeyHash = // #endregion spend-tests -// The backup key the vault is compiled around. It arrives in **parameters**, -// alongside the `Recover` action it guards. -// #region recover-tests -const recovery: VerificationKeyHash = +// The admin key the vault is compiled around. It arrives in **parameters**, +// alongside the `AdminUnlock` action it guards. +// #region admin-tests +const admin: VerificationKeyHash = #"00000000000000000000000000000000000000000000000000000003" -// #endregion recover-tests +// #endregion admin-tests // #region spend-tests const dummy_ref: OutputReference = @@ -118,7 +120,9 @@ const policy: PolicyId = // #region spend-tests test unlock_ok_when_the_owner_signs() { let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } - vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) + // #region spend-call + vault.spend(admin, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) + // #endregion spend-call } test unlock_fails_for_a_stranger() fail { @@ -126,26 +130,26 @@ test unlock_fails_for_a_stranger() fail { ..transaction.placeholder, extra_signatories: [stranger], } - vault.spend(recovery, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) + vault.spend(admin, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) } // #endregion spend-tests -// #region recover-tests -test recover_ok_when_the_recovery_key_signs() { +// #region admin-tests +test admin_unlock_ok_when_the_admin_signs() { let tx = Transaction { ..transaction.placeholder, - extra_signatories: [recovery], + extra_signatories: [admin], } - vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) + vault.spend(admin, Some(VaultDatum { owner }), AdminUnlock, dummy_ref, tx) } // The important one: the two actions are genuinely separate. Being the owner -// does not let you take the `Recover` path, and vice versa. -test recover_fails_when_the_owner_signs() fail { +// does not let you take the `AdminUnlock` path, and vice versa. +test admin_unlock_fails_when_the_owner_signs() fail { let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } - vault.spend(recovery, Some(VaultDatum { owner }), Recover, dummy_ref, tx) + vault.spend(admin, Some(VaultDatum { owner }), AdminUnlock, dummy_ref, tx) } -// #endregion recover-tests +// #endregion admin-tests // A property, not an example: the rule has to hold for *every* owner, not just // the one the tests happen to name. Aiken generates the keys and, if it finds a @@ -157,7 +161,7 @@ test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { extra_signatories: [any_owner], } vault.spend( - recovery, + admin, Some(VaultDatum { owner: any_owner }), Unlock, dummy_ref, @@ -170,7 +174,7 @@ test unlock_ok_for_any_owner(any_owner via fuzz.bytearray()) { test mint_ok_for_a_correctly_named_token() { let tx = Transaction { ..transaction.placeholder, - mint: assets.from_asset(policy, vault_token, 1), + mint: assets.from_asset(policy, token_name, 1), } vault_policy.mint(Void, policy, tx) } @@ -178,7 +182,7 @@ test mint_ok_for_a_correctly_named_token() { test burn_ok_for_a_correctly_named_token() { let tx = Transaction { ..transaction.placeholder, - mint: assets.from_asset(policy, vault_token, -1), + mint: assets.from_asset(policy, token_name, -1), } vault_policy.mint(Void, policy, tx) } diff --git a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak index 1295f1633c..cb05c8075a 100644 --- a/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak @@ -1,4 +1,4 @@ -// `vault.ak` part way through the track: no `recovery` parameter, no minting +// `vault.ak` part way through the track: no `admin` parameter, no minting // policy. Lectures 4, 5 and 6 render from here, so their pages show the file as // it looked at that point. It sits in `validators/` so the compiler checks it, // which is also why `plutus.json` carries `vault_simple.*` entries nothing reads. @@ -11,9 +11,15 @@ use aiken/crypto.{VerificationKeyHash} // The generators the property test draws its keys from. Added in **testing**. // #region simple-fuzz-import use aiken/fuzz +// #endregion simple-fuzz-import + +// The handler's own argument types. Added in **datum & redeemer**, beside the +// key hash above; `aiken fmt` sorts the imports, which is why the two sit apart +// here and lecture 4 renders them as one block. +// #region import-transaction use cardano/transaction.{OutputReference, Transaction} -// #endregion simple-fuzz-import +// #endregion import-transaction // The datum: who owns *this* locked UTxO. It changes from one UTxO to the next, // so it belongs on the UTxO. @@ -90,7 +96,9 @@ const dummy_ref: OutputReference = // fills in the one field this rule reads. test unlock_ok_when_the_owner_signs() { let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } + // #region spend-call vault.spend(Some(VaultDatum { owner }), Unlock, dummy_ref, tx) + // #endregion spend-call } // The rule is only worth anything if it also says no. `fail` is how a test From e30a8e87ff08054008333e2c0ce17dc6ae182906 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Tue, 1 Sep 2026 11:27:49 -0300 Subject: [PATCH 77/89] Spell it policy ID across the intermediate track --- .../lectures/intermediate/5-transaction-context.md | 2 +- .../lectures/intermediate/8-validator-purposes.md | 6 +++--- .../lectures/intermediate/9-frontend-integration.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md index cf28ad22d0..8d602d6d71 100644 --- a/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -62,7 +62,7 @@ There is no `msg.sender` here, and nothing plays that role. A transaction has no `_own_ref` says which UTxO is being spent, and `self` is the transaction itself. -A `mint` handler is handed a different set, because nothing is being unlocked: no datum, no `_own_ref`, and the policy id instead. What you are given depends on the **purpose**, which has its own lecture in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. +A `mint` handler is handed a different set, because nothing is being unlocked: no datum, no `_own_ref`, and the policy ID instead. What you are given depends on the **purpose**, which has its own lecture in **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**.
diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 3189642b79..44876b9d7c 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -75,9 +75,9 @@ That is what it has been doing all along: covering **every other purpose**. If a ## A validator whose purpose is minting -Every token on Cardano is identified by two values: a **policy id** and a token name. The policy is the script that decides whether tokens under it may be created or destroyed, and the policy id is that script's hash. Two tokens sharing a name under different policy ids are different tokens. Rewriting the policy changes its hash, which makes it a different policy guarding a different token. +Every token on Cardano is identified by two values: a **policy ID** and a token name. The policy is the script that decides whether tokens under it may be created or destroyed, and the policy ID is that script's hash. Two tokens sharing a name under different policy IDs are different tokens. Rewriting the policy changes its hash, which makes it a different policy guarding a different token. -The vault's `else` refuses every purpose that is not spend, so a token needs a validator of its own (one script *can* carry every purpose at once, but in this lecture we write two validators). That validator has one purpose, `mint`, and its hash is the token's policy id. It has nothing to do with the vault's address. +The vault's `else` refuses every purpose that is not spend, so a token needs a validator of its own (one script *can* carry every purpose at once, but in this lecture we write two validators). That validator has one purpose, `mint`, and its hash is the token's policy ID. It has nothing to do with the vault's address. This token is an example, not part of the vault. Nothing in the vault's validator mentions it, and nothing in the policy mentions the vault. @@ -129,7 +129,7 @@ Read the arguments, because they differ from `spend`. **No datum reaches this ha `self.mint` holds everything the transaction creates or destroys, under every policy. `assets.tokens` gives back only the tokens minted under this one, as a dictionary of token name to amount, and `dict.to_pairs` turns that into a list. Matching the list against `[Pair(name, _)]` succeeds only if it holds exactly one entry, so the transaction cannot mint a second name under this policy. `name == token_name` then decides which name that has to be. -**Notice which script this is.** The vault takes `admin` as a parameter and this policy takes none, so the two hashes move independently, and the policy needs nothing applied to it before you use it. Change your admin key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy id stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. +**Notice which script this is.** The vault takes `admin` as a parameter and this policy takes none, so the two hashes move independently, and the policy needs nothing applied to it before you use it. Change your admin key and the vault's address changes, from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. The policy ID stays exactly where it was, because there is nothing in it to change. Every reader of this track ends up with a different vault and the same token. ```bash aiken check diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index e0363ac329..a00f1339be 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -45,7 +45,7 @@ Locking is an ordinary payment that happens to be addressed to a script, with th Your wallet signing a transaction is not the same as your key hash appearing in the transaction's required-signers field. That field is `extra_signatories`, the one your vault reads in **[the transaction context](/docs/developers/onboarding/lectures/intermediate/transaction-context)**, and asking for it is a separate step from signing. Forget it and the signature is there but the validator cannot see it, so a correct contract refuses a legitimate spend. -Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy id, from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. Minting does add collateral, because it runs a script, and a plain lock does not. +Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy ID, from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. Minting does add collateral, because it runs a script, and a plain lock does not. ## Collateral, and what a script costs From 1d3b48baea60c92f2f04b31632aee53c56095384 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:51:24 -0300 Subject: [PATCH 78/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index a00f1339be..93094d9dc0 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -20,7 +20,7 @@ import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermedia # Off-chain and frontend integration -Your contract is finished. It compiles, its eight tests pass, and it has a hash. And it can do nothing at all, because **a contract cannot act**. +Your contract is finished. It compiles, passes its eight tests, and has a hash. But it can do nothing at all, because **a contract is a passive entity. It cannot act by itself**. That's the off-chain's job. That something is your app, and this lecture is the whole of it. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Everything on that side of the line arrives here. By the end you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. From 10d3bc587efde03276319cfb36347ab1f29702a4 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:51:43 -0300 Subject: [PATCH 79/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 93094d9dc0..484ecc2587 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -22,7 +22,7 @@ import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermedia Your contract is finished. It compiles, passes its eight tests, and has a hash. But it can do nothing at all, because **a contract is a passive entity. It cannot act by itself**. That's the off-chain's job. -That something is your app, and this lecture is the whole of it. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Everything on that side of the line arrives here. By the end you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. +**[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Well, it's time to fill it. By the end of this lectrue, you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. It arrives all at once for a reason. The contract is where the thinking is, and it changed with every lecture: a datum, a rule, a parameter, a second purpose. The off-chain half barely changes at all. It is the same few builders every time: derive the address, attach the datum, spend the UTxO. Writing them against a contract that has stopped moving is far easier than rewriting them six times as the contract grows. From 4ec6a6d1989fabcf65972f1cae947f9d685df77e Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:52:05 -0300 Subject: [PATCH 80/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 484ecc2587..16f72a31c6 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -24,7 +24,7 @@ Your contract is finished. It compiles, passes its eight tests, and has a hash. **[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Well, it's time to fill it. By the end of this lectrue, you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. -It arrives all at once for a reason. The contract is where the thinking is, and it changed with every lecture: a datum, a rule, a parameter, a second purpose. The off-chain half barely changes at all. It is the same few builders every time: derive the address, attach the datum, spend the UTxO. Writing them against a contract that has stopped moving is far easier than rewriting them six times as the contract grows. +The good thing is that, unlike when we wrote the contract, this part mostly comes from your previous choices and is more mechanical because the off-chain has many repeated parts across protocols: deriving the address, attaching the datum, spending the UTxO, etc. **You write all of it.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove the whole thing before a wallet is ever connected. From cbd4b9744136da277cfcad7eb02b1888235e77a3 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:52:21 -0300 Subject: [PATCH 81/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 16f72a31c6..5f2ef347b2 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -26,7 +26,7 @@ Your contract is finished. It compiles, passes its eight tests, and has a hash. The good thing is that, unlike when we wrote the contract, this part mostly comes from your previous choices and is more mechanical because the off-chain has many repeated parts across protocols: deriving the address, attaching the datum, spending the UTxO, etc. -**You write all of it.** Six files carry a Cardano idea: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove the whole thing before a wallet is ever connected. +**You write all of it.** Six files contain the actual off-chain: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove it all before a wallet is ever connected. ## The bridge: from blueprint to address From 03d4ad2540e62cdbe1d78b4a4cca587d661f1f9a Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:52:36 -0300 Subject: [PATCH 82/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 5f2ef347b2..d5e371aadd 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -30,7 +30,7 @@ The good thing is that, unlike when we wrote the contract, this part mostly come ## The bridge: from blueprint to address -The off-chain side starts from `plutus.json`, the file your compiler wrote. It holds the compiled validator. Filling in its parameter finishes the script, and hashing the finished script gives the **address**. **[Parameters](/docs/developers/onboarding/lectures/intermediate/parameters#why-a-parameter-changes-the-address)** drew that chain and promised you the two lines of code at the end of it. You write them below, in the first file you create. +The off-chain side starts from `plutus.json`, the file your compiler wrote. It holds the compiled validator. Filling in its parameter finishes the script, and hashing the finished script gives the **address**. Deriving the address is not a deployment. The address exists because the contract exists, so you could work it out on a computer that has never been online, and anyone with the same contract and the same parameter arrives at the same address. From 63f18e2eb172cad4aa87d77b181f7752f0841074 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:53:58 -0300 Subject: [PATCH 83/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index d5e371aadd..4ab1691ec0 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -36,7 +36,7 @@ Deriving the address is not a deployment. The address exists because the contrac ## Lock, then unlock -Locking is an ordinary payment that happens to be addressed to a script, with the datum attached to the output, exactly as **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator#locking-is-just-a-payment)** described. **Unlocking is where the contract runs.** That transaction still carries everything a plain payment does, its inputs, outputs, fee, signatures and validity window, and it carries four things a plain payment never needs: +Locking is an ordinary payment that happens to be addressed to a script, with the datum attached to the output, exactly as **[what a validator is](/docs/developers/onboarding/lectures/intermediate/what-is-a-validator#locking-is-just-a-payment)** described. **Unlocking is where the `spend` script runs.** That transaction still carries everything a plain payment does: its inputs, outputs, fee, signatures, and validity window, but, on top of those, it carries some things a plain payment never needs: - the **script** itself, because the network cannot run a program it has not been given. - the **redeemer**, because the validator has to be told which action you are taking. From c71a87f02c44ea44df0b9caa1165284368f55c31 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:54:56 -0300 Subject: [PATCH 84/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 4ab1691ec0..1c08bc0e4c 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -52,7 +52,7 @@ Minting adds nothing conceptually. The token has a policy script of its own, and Collateral is a deposit the network takes only when a script fails after passing structural checks. The rules are in **[fees](/docs/developers/curriculum/fundamentals/core-concepts/fees#collateral)** and the two-phase model behind them is in **[transaction failures](/docs/developers/curriculum/start-building/transaction-failures#the-two-phase-model)**. Three things about it are specific to what you are building: - It must hold **only ADA**, and it must sit at a **plain key address** with no script guarding it. Otherwise the network would need to run a second script just to collect the deposit. -- **In normal use it is never taken**, because the validator runs before you send anything. In the tests below that happens on your own machine. In the page, the job goes to the **provider**, the service that reads the chain for you, which is Blockfrost here. +- **In normal use, it is never taken**. For collateral to be taken, you have to be trying to get away with something you're not allowed to do. If something is wrong, It'll be caught by either your tooling (your tests or the **provider**, the service that reads the chain for you, which is Blockfrost here) before submitting the transaction. - This is the first project whose **code** reads the chain, which is why it needs a Blockfrost key when the Beginner track never did. The builder resolves inputs and fee settings through the provider, and the check before you send asks it to run your script as well. :::tip Set collateral once and forget it From 40dd4669164a20b9ea9a99210c7c5d2d36f0fe7d Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:55:15 -0300 Subject: [PATCH 85/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 1c08bc0e4c..e729f5d197 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -65,7 +65,7 @@ Unlocking also costs more than locking, because it runs a program and that is pr Every builder below ends the same way: it returns an **unsigned transaction**, which the **wallet** signs and submits. Your code never sees a key. That division is [CIP-30](/docs/developers/curriculum/dapps/connect-a-wallet#what-cip-30-gives-you), the interface every Cardano wallet exposes to a page, which is why an app written for one wallet works with the rest. -The wallet signs an unlock **partially**: it signs the inputs it owns and leaves the rest alone. One of those inputs is the locked UTxO, and it sits at a script address, where no key can sign for anything. Whether it may be spent is the validator's decision, made when the network runs it. +The wallet signs an unlock **partially**: it signs the inputs it owns and leaves the rest alone. One of those inputs is the vault UTxO, and it sits at a script address, where no key can sign for anything. The validator decides whether it may be spent when the network runs it. ## The browser cannot keep a secret From 72f32c4e1f7862e17b02c8fcfbc664c068297a96 Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:56:12 -0300 Subject: [PATCH 86/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index e729f5d197..fd83c260df 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -99,7 +99,7 @@ sequenceDiagram W->>Net: signed (partially), submitted Net->>Net: run the validator: is the datum's owner
among the transaction's signers? Net-->>W: yes - Vault->>App: the 5 ADA comes back + Vault->>W: the 5 ADA comes back ``` ## Try it From be53a5c901cda64127392e3db183652a10168f9d Mon Sep 17 00:00:00 2001 From: Paulo Bressan Date: Wed, 2 Sep 2026 10:57:53 -0300 Subject: [PATCH 87/89] Update docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md Co-authored-by: Robertino --- .../onboarding/lectures/intermediate/9-frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index fd83c260df..60d7dd6cd3 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -126,7 +126,7 @@ npm install @meshsdk/core@^1.9.1 @meshsdk/core-csl@^1.9.1 @meshsdk/wallet@^1.9.1 mkdir off-chain/src off-chain/src/lib ``` -The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser. +The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser (to test locally before we havea frontend). Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed. From 14439b56bea3ada07d1980db01223ff00d1caacb Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 2 Sep 2026 19:14:00 -0300 Subject: [PATCH 88/89] Apply PR review feedback to intermediate lecture 9 --- .../intermediate/9-frontend-integration.md | 223 +++++++++++++++--- .../vault/off-chain/mesh/src/app.tsx | 5 +- .../vault/off-chain/mesh/src/main.tsx | 6 +- .../vault/off-chain/mesh/tsconfig.json | 3 + 4 files changed, 198 insertions(+), 39 deletions(-) diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 60d7dd6cd3..061a16a392 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -17,6 +17,7 @@ import MintLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediat import OfflineTests from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts"; import Minimal from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx"; import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts"; +import Tsconfig from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json"; # Off-chain and frontend integration @@ -47,6 +48,14 @@ Your wallet signing a transaction is not the same as your key hash appearing in Minting adds nothing conceptually. The token has a policy script of its own, and its hash is the policy ID, from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**. Minting does add collateral, because it runs a script, and a plain lock does not. +## How the datum and the redeemer are stored + +On-chain data is stored as a **numbered constructor plus a list of fields**. The number answers "which choice of the type is this?", and the list answers "what does it hold?". The number comes from position: the first choice declared is 0, the next is 1, and so on. + +`VaultDatum` offers one shape, so it is constructor 0 carrying one field, the owner. `VaultAction` offers two, so `Unlock` is constructor 0 and `AdminUnlock` is constructor 1, each carrying nothing. + +Your off-chain code has to build those same bytes from the other side, and nothing checks that the two sides agree. Send constructor 1 where you meant `Unlock` and the validator reads `AdminUnlock`, and acts on it. + ## Collateral, and what a script costs Collateral is a deposit the network takes only when a script fails after passing structural checks. The rules are in **[fees](/docs/developers/curriculum/fundamentals/core-concepts/fees#collateral)** and the two-phase model behind them is in **[transaction failures](/docs/developers/curriculum/start-building/transaction-failures#the-two-phase-model)**. Three things about it are specific to what you are building: @@ -132,26 +141,9 @@ Note where that `package.json` landed: the **workspace root**, not inside `off-c One more file, so your editor understands the code you are about to write. Create `tsconfig.json` beside `package.json`: -```json title="tsconfig.json" -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "module": "esnext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "noEmit": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "isolatedModules": true, - "types": ["node", "vite/client"] - }, - "include": ["off-chain/src"] -} -``` + + {extractRegion(Tsconfig, "file")} + `skipLibCheck` stops TypeScript checking Mesh's own dependencies and reporting errors from libraries you never imported. `types` brings in Node's globals, which the tests need, and Vite's, which is what makes `import.meta.env` a known thing. `resolveJsonModule` lets you import `plutus.json`. And `allowImportingTsExtensions` is what lets your imports say `./lib/lock.ts`, extension and all, the way Node runs them. @@ -182,49 +174,212 @@ The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/interm {extractRegion(Datum, "file")} -`mConStr0` names a constructor by number, which is how a type reaches a validator: by the position it was declared in, not by its name. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, which is the `VaultDatum { owner }` your validator expects. `mConStr0([])` is constructor 0 carrying nothing, which is `Unlock`. And `mConStr1([])` is constructor 1, which is `AdminUnlock`, because it is declared second in `VaultAction`. +`mConStr0` and `mConStr1` are how Mesh writes the [numbered constructors](#how-the-datum-and-the-redeemer-are-stored) above. `mConStr0([ownerPubKeyHash])` is constructor 0 carrying one field, the `VaultDatum { owner }` your validator expects. `mConStr0([])` is `Unlock`, and `mConStr1([])` is `AdminUnlock`. + +
+ + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. The idea is identical, only the library calls differ. + + +
### 4. The four transactions -These are the whole off-chain half: lock funds, find them again, unlock them, and mint the vault's own token. First `off-chain/src/lib/lock.ts`: +These are the logic of your off-chain code: lock funds, find them again, unlock them, and mint the vault's own token. + +#### Lock + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I["`**your UTxO** + address: you + value: 10 ADA`"] + end + + TX{{"`**lock** + fee: 0.17 ADA + no script runs`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O1["`**locked** + address: the vault + value: 5 ADA + datum: owner = your key hash`"] + O2["`**change** + address: you + value: 4.83 ADA`"] + end + + I --> TX --> O1 + TX --> O2 + + style I stroke-dasharray:4 3 +``` + +An ordinary payment. One output goes to the vault's address and carries the datum, and the rest comes back to you as change. No script, no redeemer and no collateral, because the contract does not run when you lock. + + + + +Create `off-chain/src/lib/lock.ts`: {extractRegion(LockLib, "file")} -An ordinary payment, with two additions. `deserializeAddress(...).pubKeyHash` pulls your key hash out of your address, which is what goes in the datum, and `.txOutInlineDatumValue(...)` attaches that datum to the output. No script, no collateral, no redeemer: the contract does not run when you lock. +`deserializeAddress(...).pubKeyHash` pulls your key hash out of your address, which is what goes in the datum, and `.txOutInlineDatumValue(...)` attaches that datum to the output. + + + -Then `off-chain/src/lib/unlock.ts`, which is where the contract does run: +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + + + +#### Find what is locked + +Nothing is spent and nothing is created here. A script address is an ordinary address, so this is the same [UTxO query](/docs/developers/curriculum/start-building/query-the-chain#datums) you have made since Beginner. + +**The vault's address is not yours.** Anyone who compiled the same contract with the same parameter arrives at the same address, so what sits there is everyone's UTxOs mixed together. The only thing that says which are yours is the `owner` in each datum, which is exactly what your validator will check later. + + + + +Create `off-chain/src/lib/fetch.ts`: + + + {extractRegion(FetchLib, "file")} + + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + + + +#### Unlock + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I1["`**the locked UTxO** + address: the vault + value: 5 ADA + datum: owner = your key hash`"] + I2["`**your UTxO** + address: you + value: 4.83 ADA`"] + end + + TX{{"`**unlock** + fee: 0.35 ADA + the spend validator runs + redeemer: Unlock + your key hash in extra_signatories + collateral offered, not taken`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O["`**back to you** + address: you + value: 9.48 ADA`"] + end + + I1 --> TX --> O + I2 --> TX + + style I1 stroke-dasharray:4 3 + style I2 stroke-dasharray:4 3 +``` + +This is where the contract runs. A script spend needs four things that a plain payment does not: the script, the redeemer, the required signer entry, and the collateral. **[Lock, then unlock](#lock-then-unlock)** above says what each one is for. Unlocking costs more than locking, because the network runs a program. + + + + +Create `off-chain/src/lib/unlock.ts`: {extractRegion(UnlockLib, "file")} -The four things a script spend adds each get a line. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. +Those four each get a line. `.txInScript` carries the compiled contract, `.txInRedeemerValue` says which action you are taking, `.txInCollateral` offers the deposit, and `.requiredSignerHash(owner)` is the one people forget: it puts your key hash in `extra_signatories`, which is the list your validator actually reads. The rest say what is being spent. `.spendingPlutusScriptV3()` declares that this input is guarded by a script, `.txIn(...)` names the locked UTxO, and `.txInInlineDatumPresent()` says its datum is already on the chain, so there is nothing to attach. Passing an **evaluator** makes the builder run your **real compiled validator** before it returns anything. A spend the contract would refuse fails here, immediately, instead of on the chain where it would cost you the collateral. -Next `off-chain/src/lib/fetch.ts`, because you cannot unlock what you cannot find. A script address is an ordinary address, so this is the same [UTxO query](/docs/developers/curriculum/start-building/query-the-chain#datums) you have made since Beginner: + + - - {extractRegion(FetchLib, "file")} - +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. -**The vault's address is not yours.** Anyone who compiled the same contract with the same parameter arrives at the same address, so what sits there is everyone's UTxOs mixed together. The only thing that says which are yours is the `owner` in each datum, which is exactly what your validator will check later. + + + +#### Mint and lock + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I["`**your UTxO** + address: you + value: 10 ADA`"] + end + + TX{{"`**mint and lock** + fee: 0.3 ADA + the policy's mint handler runs + mint: +1 TOKEN A + collateral offered, not taken`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O1["`**locked** + address: the vault + value: 5 ADA + 1 TOKEN A + datum: owner = your key hash`"] + O2["`**change** + address: you + value: 4.7 ADA`"] + end + + I --> TX --> O1 + TX --> O2 + + style I stroke-dasharray:4 3 +``` + +The token is in no input. Minting is the one thing that creates value, and the policy from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is what decides whether it may. One script runs here, and it is that policy. The vault's own validator does not, because this transaction creates an output at the vault's address instead of spending one, and sending to a script address never runs the script. + + + -Last `off-chain/src/lib/mint.ts`, which is `lock.ts` plus the mint from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)**, in one transaction: +Create `off-chain/src/lib/mint.ts`: {extractRegion(MintLib, "file")} -`vaultTokenPolicyId()` hashes the policy script, the second one in your blueprint, so the value it returns has nothing to do with the vault's address. Two scripts run in this transaction: `.mintingScript(...)` carries the policy so the network can ask it about the token, and the output goes to `vaultAddress(...)`. +`vaultTokenPolicyId()` hashes the policy script, the second one in your blueprint, so the value it returns has nothing to do with the vault's address. `.mintingScript(...)` carries the policy so the network can ask it about the token, and the output goes to `vaultAddress(...)`. + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + + + + + -### 5. Prove it offline +### 5. Integration testing -**[Testing](/docs/developers/onboarding/lectures/intermediate/testing)** named a third level of testing it could not reach, because there was no app to test. There is now, and it needs no network. +**Integration testing** is what **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** could not reach, because there was no app to test. There is one now, and this needs no network. Create `off-chain/src/vault.test.ts`. The imports first: diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx index 5bb2207ca9..21d28edd11 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -59,8 +59,9 @@ function App() { run(() => buildLockTx(wallet!, provider, NETWORK_ID, "5000000")); } - // The same lock, plus one token minted under the vault's policy script. One - // transaction running two scripts, from **validator purposes**. + // The same lock, plus one token minted under the vault's policy script, from + // **validator purposes**. Only that policy runs: creating an output at the + // vault's address does not run the vault's own validator. function mintAndLock() { run(() => buildMintAndLockTx(wallet!, provider, NETWORK_ID, "5000000")); } diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx index b26ded8975..fa8ef7160c 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx @@ -152,9 +152,9 @@ function App() { Mint & lock 5 ADA

- The second button also mints one TOKEN A token and locks it with the ADA. Two scripts - run in that one transaction: the policy decides the token may exist, and the vault's - address is where it lands. Unlocking brings both back. + The second button also mints one TOKEN A token and locks it with the ADA. One script + runs in that transaction: the policy, which decides the token may exist. The vault's own + validator does not run until you unlock, which brings both back.

diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json index 5d94abc30c..e48b22d786 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json @@ -1,3 +1,4 @@ +// #region file { "compilerOptions": { "target": "ES2022", @@ -14,6 +15,8 @@ "isolatedModules": true, "types": ["node", "vite/client"] }, + // #replace "include": ["src"] -> "include": ["off-chain/src"] "include": ["src"], "exclude": ["node_modules"] } +// #endregion file From 05083a7497a551cab985007330c7b4b9ea6d630f Mon Sep 17 00:00:00 2001 From: paulobressan Date: Fri, 4 Sep 2026 11:39:33 -0300 Subject: [PATCH 89/89] Add mint and burn flows and move the app into off-chain/ --- .../intermediate/8-validator-purposes.md | 4 +- .../intermediate/9-frontend-integration.md | 209 +++++++++++++----- .../vault/off-chain/mesh/src/app.tsx | 55 ++++- .../vault/off-chain/mesh/src/lib/lock.ts | 14 +- .../vault/off-chain/mesh/src/lib/mint.ts | 7 +- .../vault/off-chain/mesh/src/lib/token.ts | 94 ++++++++ .../vault/off-chain/mesh/src/main.tsx | 76 ++++++- .../vault/off-chain/mesh/src/vault.test.ts | 58 ++++- .../vault/off-chain/mesh/tsconfig.json | 1 - 9 files changed, 446 insertions(+), 72 deletions(-) create mode 100644 examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/token.ts diff --git a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md index 44876b9d7c..78af35e72e 100644 --- a/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -38,11 +38,11 @@ A **single validator** can handle **several purposes at once**, and it has exact ```mermaid flowchart TD S["your validator,
compiled"] -->|hash it| H["one script hash"] - H -->|works as payment credential (inside address)| A["`**spend** + H -->|"works as payment credential (inside address)"| A["`**spend** guards the UTxOs locked there`"] H -->|works as a policy ID| P["`**mint** guards tokens issued under it`"] - H -->|works as stake credential (inside address)| W["`**withdraw** + H -->|"works as stake credential (inside address)"| W["`**withdraw** guards reward withdrawals`"] ``` diff --git a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md index 061a16a392..bc9356b511 100644 --- a/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -17,17 +17,18 @@ import MintLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediat import OfflineTests from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts"; import Minimal from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx"; import VercelFn from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/api/blockfrost/[...path].ts"; +import TokenLib from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/token.ts"; import Tsconfig from "!!raw-loader!@site/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json"; # Off-chain and frontend integration Your contract is finished. It compiles, passes its eight tests, and has a hash. But it can do nothing at all, because **a contract is a passive entity. It cannot act by itself**. That's the off-chain's job. -**[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Well, it's time to fill it. By the end of this lectrue, you will have a page in a browser with a **Connect wallet** button, a **Lock** button and an **Unlock** button, driving the vault you wrote. +**[On-chain vs off-chain](/docs/developers/onboarding/lectures/intermediate/on-chain-vs-off-chain)** drew the line at the start of this track and left the `off-chain/` folder empty. Well, it's time to fill it. By the end of this lecture, you will have a page in a browser that connects a wallet, mints and burns the token, and locks and unlocks real test ADA, driving the contract and the policy you wrote. The good thing is that, unlike when we wrote the contract, this part mostly comes from your previous choices and is more mechanical because the off-chain has many repeated parts across protocols: deriving the address, attaching the datum, spending the UTxO, etc. -**You write all of it.** Six files contain the actual off-chain: the address, the datum, and the four transactions your page sends. The rest is the page, its config, and the tests that prove it all before a wallet is ever connected. +**You write all of it.** Seven files contain the actual off-chain: the address, the datum, the four transactions your page sends, and the query that finds what you locked. The rest is the page, its config, and the tests that prove it all before a wallet is ever connected. ## The bridge: from blueprint to address @@ -113,13 +114,13 @@ sequenceDiagram ## Try it -**Fill `off-chain/`.** You have been inside `on-chain/vault/` since **[set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. From there, go up two levels to the workspace root: +**Fill `off-chain/`.** You have been inside `on-chain/vault/` since **[set up your tools](/docs/developers/onboarding/lectures/intermediate/tools)**. Move across to the other folder: ```bash -cd ../.. # from cardano-vault/on-chain/vault/ back to cardano-vault/ +cd ../../off-chain # from cardano-vault/on-chain/vault/ to cardano-vault/off-chain/ ``` -That is the last folder change in the track. Every command from here runs from `cardano-vault/`. +That is the last folder change in the track. Every command from here runs from `cardano-vault/off-chain/`, the way every `aiken` command ran from `on-chain/vault/`. @@ -132,13 +133,11 @@ You need **[Node.js](https://nodejs.org/) 22.18 or newer**, because from that ve npm init -y npm pkg set type=module npm install @meshsdk/core@^1.9.1 @meshsdk/core-csl@^1.9.1 @meshsdk/wallet@^1.9.1 -mkdir off-chain/src off-chain/src/lib +mkdir src src/lib ``` The SDK project is just a `package.json`. `npm pkg set type=module` switches it to modern `import` syntax, which the SDK uses. Of the three packages, `@meshsdk/core` is Mesh itself, `@meshsdk/core-csl` is the **evaluator** that runs a compiled validator on your own machine, and `@meshsdk/wallet` is a wallet that signs without a browser (to test locally before we havea frontend). -Note where that `package.json` landed: the **workspace root**, not inside `off-chain/`. `npm` acts on the folder holding `package.json`, and `node` looks there for the packages it installed. - One more file, so your editor understands the code you are about to write. Create `tsconfig.json` beside `package.json`: @@ -149,15 +148,15 @@ One more file, so your editor understands the code you are about to write. Creat ### 2. From blueprint to address -The first file you write, and the bridge the top of this lecture describes. Create `off-chain/src/lib/blueprint.ts`: +The first file you write, and the bridge the top of this lecture describes. Create `src/lib/blueprint.ts`: - + {extractRegion(Blueprint, "file")} Four things in it: -- **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`. This is the only place the two halves of your workspace touch, and it is a file, not a network call. +- **The import path** reaches across into the other half of your workspace: from `off-chain/src/lib/` that is `"../../../on-chain/vault/plutus.json"`, three levels up and back down. This is the only place the two halves of your workspace touch, and it is a file, not a network call. - **The title** `vault.vault.spend` is `..`, so it names your `vault.ak`, its `vault` validator, and its spend handler. - **`applyParamsToScript`** fills the blank from **[parameters](/docs/developers/onboarding/lectures/intermediate/parameters)**. These are the two lines that lecture promised you. - **`ADMIN`** is that parameter, and it decides the address. Any 56-character hex string works, which is 28 bytes written out. @@ -168,9 +167,9 @@ It is part of the script, so it is part of the hash, so it is part of the addres ### 3. The datum and the redeemers -The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, now built from the other side. Create `off-chain/src/lib/datum.ts`: +The shapes from **[datum & redeemer](/docs/developers/onboarding/lectures/intermediate/datum-and-redeemer)**, now built from the other side. Create `src/lib/datum.ts`: - + {extractRegion(Datum, "file")} @@ -184,11 +183,11 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming -### 4. The four transactions +### 4. The transactions -These are the logic of your off-chain code: lock funds, find them again, unlock them, and mint the vault's own token. +These are the logic of your off-chain code, and together they cover the token and the vault: mint, lock, find, unlock, burn, and one transaction that mints and locks at once. -#### Lock +#### Mint the token ```mermaid flowchart LR @@ -198,18 +197,68 @@ flowchart LR value: 10 ADA`"] end + TX{{"`**mint** + fee: 0.3 ADA + the policy's mint handler runs + mint: +3 TOKEN A + collateral offered, not taken`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O["`**back to you** + address: you + value: 9.7 ADA + 3 TOKEN A`"] + end + + I --> TX --> O + + style I stroke-dasharray:4 3 +``` + +Minting is the only process that creates new tokens. The policy explained in the **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** decides if minting is allowed. This policy only checks the token's name, it allows any quantity, so making three tokens at once works just like making one. The vault plays no role here, and the new tokens go directly to your wallet address. + + + + +Create `src/lib/token.ts`: + + + {extractRegion(TokenLib, "file")} + + +`vaultTokenPolicyId()` hashes the policy script. Its output is not connected to the vault's address at all. The `.mint(...)` function receives the token amount as a text string, while `.mintingScript(...)` attaches the policy so the network can check the token rules. Because no specific destination address is named, the transaction builder sends the new tokens to your change address. + +The file also holds `fetchTokenBalance`, which is how the page shows what you own. A token is filed on the chain under its **unit**, the policy id followed by the name in hex, and a balance is the amount sitting under that unit. + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + + + +#### Lock + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I["`**your UTxO** + address: you + value: 9.7 ADA + 3 TOKEN A`"] + end + TX{{"`**lock** - fee: 0.17 ADA + fee: 0.2 ADA no script runs`"}} subgraph OUT["OUTPUTS: UTxOs created"] O1["`**locked** address: the vault - value: 5 ADA + value: 5 ADA + 3 TOKEN A datum: owner = your key hash`"] O2["`**change** address: you - value: 4.83 ADA`"] + value: 4.5 ADA`"] end I --> TX --> O1 @@ -220,17 +269,32 @@ flowchart LR An ordinary payment. One output goes to the vault's address and carries the datum, and the rest comes back to you as change. No script, no redeemer and no collateral, because the contract does not run when you lock. +**A UTxO holds a bundle, not an amount.** The output above carries 5 ADA and the three tokens you just minted, in one UTxO. It could just as well carry ADA on its own, or a token you got from somewhere else entirely: the vault's validator never looks at what a UTxO holds, only at who signed to take it. That is why locking tokens needs nothing new on-chain, and why whatever goes in comes back out at the unlock. + -Create `off-chain/src/lib/lock.ts`: +Create `src/lib/lock.ts`: - + {extractRegion(LockLib, "file")} `deserializeAddress(...).pubKeyHash` pulls your key hash out of your address, which is what goes in the datum, and `.txOutInlineDatumValue(...)` attaches that datum to the output. +The `assets` argument is the bundle, and it is the only difference between locking ADA and locking ADA with tokens: + +```ts +// 5 ADA on its own +buildLockTx(wallet, provider, networkId, [{ unit: "lovelace", quantity: "5000000" }]); + +// the same 5 ADA, with three of your tokens in the same UTxO +buildLockTx(wallet, provider, networkId, [ + { unit: "lovelace", quantity: "5000000" }, + { unit: tokenUnit(), quantity: "3" }, +]); +``` + @@ -248,9 +312,9 @@ Nothing is spent and nothing is created here. A script address is an ordinary ad -Create `off-chain/src/lib/fetch.ts`: +Create `src/lib/fetch.ts`: - + {extractRegion(FetchLib, "file")} @@ -269,11 +333,11 @@ flowchart LR subgraph IN["INPUTS: UTxOs spent"] I1["`**the locked UTxO** address: the vault - value: 5 ADA + value: 5 ADA + 3 TOKEN A datum: owner = your key hash`"] I2["`**your UTxO** address: you - value: 4.83 ADA`"] + value: 4.5 ADA`"] end TX{{"`**unlock** @@ -286,7 +350,7 @@ flowchart LR subgraph OUT["OUTPUTS: UTxOs created"] O["`**back to you** address: you - value: 9.48 ADA`"] + value: 9.15 ADA + 3 TOKEN A`"] end I1 --> TX --> O @@ -301,9 +365,9 @@ This is where the contract runs. A script spend needs four things that a plain p -Create `off-chain/src/lib/unlock.ts`: +Create `src/lib/unlock.ts`: - + {extractRegion(UnlockLib, "file")} @@ -318,6 +382,47 @@ Passing an **evaluator** makes the builder run your **real compiled validator** An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + +#### Burn the token + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I["`**your UTxO** + address: you + value: 9.15 ADA + 3 TOKEN A`"] + end + + TX{{"`**burn** + fee: 0.3 ADA + the policy's mint handler runs + mint: -1 TOKEN A + collateral offered, not taken`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O["`**back to you** + address: you + value: 8.85 ADA + 2 TOKEN A`"] + end + + I --> TX --> O + + style I stroke-dasharray:4 3 +``` + +Burning means minting a negative amount, and it uses the same policy. To burn a token, it must sit inside a UTxO in your wallet so you can spend it in a transaction. For example, if a UTxO holds 3 tokens, you can spend that UTxO to burn 1 token (-1) and send the remaining 2 tokens to a new UTxO. + + + + +No new file. `buildTokenTx(wallet, provider, "-1")` burns one, and the sign is the only thing that changes. The `holding` filter is what picks out the UTxOs carrying the token, and the builder stops with a plain error if your wallet has none. + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + @@ -353,18 +458,18 @@ flowchart LR style I stroke-dasharray:4 3 ``` -The token is in no input. Minting is the one thing that creates value, and the policy from **[validator purposes](/docs/developers/onboarding/lectures/intermediate/validator-purposes)** is what decides whether it may. One script runs here, and it is that policy. The vault's own validator does not, because this transaction creates an output at the vault's address instead of spending one, and sending to a script address never runs the script. +Two operations in one transaction: the mint you just wrote, and the lock from earlier. One script still runs, and it is the policy. The vault's own validator does not, because this transaction creates an output at the vault's address instead of spending one, and sending to a script address never runs the script. -Create `off-chain/src/lib/mint.ts`: +Create `src/lib/mint.ts`: - + {extractRegion(MintLib, "file")} -`vaultTokenPolicyId()` hashes the policy script, the second one in your blueprint, so the value it returns has nothing to do with the vault's address. `.mintingScript(...)` carries the policy so the network can ask it about the token, and the output goes to `vaultAddress(...)`. +The mint calls are the ones from `token.ts`. What differs is the output: it goes to `vaultAddress(...)` with a datum attached, instead of back to you. @@ -381,47 +486,47 @@ An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming **Integration testing** is what **[testing](/docs/developers/onboarding/lectures/intermediate/testing)** could not reach, because there was no app to test. There is one now, and this needs no network. -Create `off-chain/src/vault.test.ts`. The imports first: +Create `src/vault.test.ts`. The imports first: - + {extractRegion(OfflineTests, "offline-imports")} Then a pretend chain and a wallet to go with it. `OfflineFetcher` is an in-memory chain you fill in yourself, and `MeshWallet` is a wallet built from a seed phrase rather than an extension. The cost-model lines are housekeeping: a pretend chain has none, and handing over the same defaults the builder would fall back to keeps the output clean: - + {extractRegion(OfflineTests, "offline-setup")} Then a few helpers for putting UTxOs on that chain. A real chain hands you a transaction hash; here you invent one, because nothing was ever submitted: - + {extractRegion(OfflineTests, "offline-helpers")} Now the first test. Locking runs no contract, so this one only has to build: - + {extractRegion(OfflineTests, "offline-lock")} And the second. It calls the very same `buildUnlockTx` your page will call, then evaluates it, which runs your **real compiled validator**. Getting an execution budget back means the contract said yes: - + {extractRegion(OfflineTests, "offline-unlock")} Run it: ```bash -node --test off-chain/src/vault.test.ts +node --test src/vault.test.ts ``` Two tests, two passes, in a few milliseconds. Node runs the TypeScript directly. Node prints one warning above that, about importing a WebAssembly module. It comes from Mesh loading the library that serialises transactions, and it is safe to ignore. -**Now break the off-chain side, and watch which layer notices.** In `off-chain/src/lib/unlock.ts`, delete the `.requiredSignerHash(owner)` line and save. +**Now break the off-chain side, and watch which layer notices.** In `src/lib/unlock.ts`, delete the `.requiredSignerHash(owner)` line and save. Your contract is untouched, and its eight tests would still pass, because nothing is wrong with the rule. @@ -439,7 +544,7 @@ Put the line back and run it once more to be sure. So the key gets its own file, which the page never reads. -First a `.env` file at the top of `cardano-vault/`, beside `package.json`, so no key is ever written into your code: +First a `.env` file at the top of `off-chain/`, beside `package.json`, so no key is ever written into your code: ```bash title=".env" BLOCKFROST_API_KEY=previewYourKeyHere @@ -466,7 +571,7 @@ npm pkg set scripts.build="vite build" `vite` is the dev server, and the `build` script is there for the last exercise in this lecture. `typescript` and the `@types/` packages are what your `tsconfig.json` from step 1 has been describing; nothing here runs `tsc`. `vite-plugin-node-polyfills` is there because Mesh reaches for Node built-ins like `Buffer` and `crypto`, which a browser does not have. -Two small files Vite needs, and they are the only ones whose paths depend on where things sit in your workspace. `index.html` goes at the top of `cardano-vault/`, beside `package.json`, because Vite serves the folder you run it from: +Two small files Vite needs. `index.html` goes at the top of `off-chain/`, beside `package.json`, because Vite serves the folder you run it from: ```html title="index.html" @@ -477,7 +582,7 @@ Two small files Vite needs, and they are the only ones whose paths depend on whe
- + ``` @@ -530,11 +635,11 @@ A redirect will not rescue the static case, because it passes the browser's head It is the same four decisions as the config. Returning `fetch(...)` straight out passes the status and body through untouched. The forwarding itself is portable, since it is plain `Request` in, `Response` out, but each host wants its own entry point: Netlify Edge Functions expect the file under `netlify/edge-functions/`, and Cloudflare Workers export `{ fetch }` and read secrets from an `env` argument rather than `process.env`. -**And none of `off-chain/src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one, which is why those builders were typed against the interface Mesh defines rather than against a particular wallet. +**And none of `src/lib/` changes here.** Until now a `MeshWallet` built from a seed phrase satisfied the `IWallet` argument your builders take. A browser wallet satisfies exactly the same one, which is why those builders were typed against the interface Mesh defines rather than against a particular wallet. -So the last file you write is the page. Create `off-chain/src/app.tsx`: +So the last file you write is the page. Create `src/app.tsx`: - + {extractRegion(Minimal, "file")} @@ -561,10 +666,12 @@ npm run dev Open the printed URL **in the browser where Lace is installed**, with Lace set to Preview and collateral already set. Then, in order: 1. **Connect wallet.** The extension asks for permission once. -2. **Lock 5 ADA.** Approve it. This is the plain payment: no contract runs. -3. **Refresh locked** after a few seconds, and your UTxO appears. -4. **Unlock.** This one runs your validator. The funds come back. -5. **Mint & lock 5 ADA.** The same lock, plus a TOKEN A minted under the policy you wrote, in one transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. +2. **Mint.** Put 3 in the box and press it. Your wallet gains three TOKEN A, minted under the policy you wrote. No vault is involved. Press **Refresh tokens** once it confirms, and the count goes up. +3. **Lock 5 ADA.** Approve it. This is the plain payment: no contract runs. Or press **Lock 5 ADA + 3 TOKEN A** to send the tokens in with it. +4. **Refresh locked** after a few seconds, and your UTxO appears. +5. **Unlock.** This one runs your validator. Everything in that UTxO comes back, tokens included. +6. **Burn.** Put 1 in the box and press it. One of your three tokens stops existing, and the other two come back as change. **Refresh tokens** again to see two. +7. **Mint & lock 5 ADA.** The same lock, plus a TOKEN A minted in the same transaction. **Refresh locked** and unlock it the same way: the token comes back with the ADA. If the page loads but **Lock** fails, look at `.env` before anything else. A Preview key starts with `preview`, and a mainnet or mistyped key shows up as a 401 on `/api/blockfrost/…` in the browser's **Network** tab. @@ -594,7 +701,7 @@ Stuck? The finished code is in the playground. See the **[introduction](/docs/de ## What you built -You started with an empty folder. You now have a contract you wrote and tested, a minting policy beside it, and an app that locks, mints and unlocks real test ADA through them. +You started with an empty folder. You now have a contract you wrote and tested, a minting policy beside it, and an app that mints, locks, unlocks and burns real test ADA and tokens through them. Six lectures went into the contract, and every one of them added something to it. One went into the app, because its shape never changed: derive the address, build a transaction, hand it to a wallet. diff --git a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx index 21d28edd11..f8383124e2 100644 --- a/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -3,9 +3,9 @@ // same idea with the admin door and some styling. Both drive the same // `./lib`, which is the code the reader writes. // #region file -/// The page: connect a wallet, lock 5 ADA, mint the vault's own token, and -/// unlock again. Every button below builds a transaction with the files in -/// `./lib`, then hands it to the wallet to sign and submit. +/// The page: connect a wallet, mint or burn the vault's own token, lock 5 ADA, +/// and unlock it again. Every button below builds a transaction with the files +/// in `./lib`, then hands it to the wallet to sign and submit. import { useState } from "react"; import { createRoot } from "react-dom/client"; import { BlockfrostProvider, BrowserWallet, deserializeAddress } from "@meshsdk/core"; @@ -16,6 +16,7 @@ import { buildLockTx } from "./lib/lock.ts"; import { buildMintAndLockTx } from "./lib/mint.ts"; import { buildUnlockTx } from "./lib/unlock.ts"; import { fetchLocked } from "./lib/fetch.ts"; +import { TOKEN_NAME, buildTokenTx, fetchTokenBalance, tokenUnit } from "./lib/token.ts"; const NETWORK_ID = Number(import.meta.env.VITE_NETWORK_ID ?? "0"); @@ -29,6 +30,8 @@ function App() { const [owner, setOwner] = useState(""); const [locked, setLocked] = useState([]); const [status, setStatus] = useState(""); + const [qty, setQty] = useState("1"); + const [tokens, setTokens] = useState("0"); async function connect() { const connected = await BrowserWallet.enable("lace"); @@ -38,6 +41,7 @@ function App() { const pubKeyHash = deserializeAddress(await connected.getChangeAddress()).pubKeyHash; setOwner(pubKeyHash); setLocked(await fetchLocked(provider, NETWORK_ID, pubKeyHash)); + setTokens(await fetchTokenBalance(connected)); } // Build, sign, submit. The `true` is a **partial** signature: the wallet signs @@ -55,8 +59,38 @@ function App() { } } + const FIVE_ADA = { unit: "lovelace", quantity: "5000000" }; + function lock() { - run(() => buildLockTx(wallet!, provider, NETWORK_ID, "5000000")); + run(() => buildLockTx(wallet!, provider, NETWORK_ID, [FIVE_ADA])); + } + + // The same lock, with tokens riding along in the same UTxO. A UTxO holds a + // bundle, and the vault's validator never looks at what is in it. + function lockWithTokens() { + run(() => + buildLockTx(wallet!, provider, NETWORK_ID, [ + FIVE_ADA, + { unit: tokenUnit(), quantity: qty }, + ]), + ); + } + + // The token on its own, in either direction. The policy checks the name and + // ignores the amount, so mint and burn differ only by the sign. + function mint() { + run(() => buildTokenTx(wallet!, provider, qty)); + } + + function burn() { + run(() => buildTokenTx(wallet!, provider, `-${qty}`)); + } + + // A read, not a transaction. Minting and burning only show up here once the + // chain has confirmed them, so this is a button rather than something + // automatic. + async function refreshTokens() { + setTokens(await fetchTokenBalance(wallet!)); } // The same lock, plus one token minted under the vault's policy script, from @@ -75,7 +109,20 @@ function App() { return (

The vault lives at {vaultAddress(NETWORK_ID)}

+ + + + +

+ You hold {tokens} {TOKEN_NAME} +

+ {" "} + {" "} + +

+ Your wallet holds {tokens} {TOKEN_NAME}. +

+

+ Both buttons build the same transaction with one sign changed: the policy checks the + token's name and lets any amount through. Minting puts the tokens in your wallet. + Burning spends the ones you hold and they stop existing. +

+ + + {" "} + {" "}