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/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..b39053342a --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/1-on-chain-vs-off-chain.md @@ -0,0 +1,127 @@ +--- +title: "On-chain vs off-chain" +sidebar_label: "On-chain vs off-chain" +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**. + +## What a dApp is + +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. + +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. + +```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 +``` + +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 + +Split any Cardano app along that line and it becomes much easier to understand: + +| 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** | +| 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. + +## 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 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: 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. + +## 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. 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.** 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 + +**Make the folder you will work in for the rest of the track:** + +```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. +``` + +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". + +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. + +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..5fa46ec4eb --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/2-tools.md @@ -0,0 +1,106 @@ +--- +title: "Set up your tools" +sidebar_label: "Set up your tools" +description: "The 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 + +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 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 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. + +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 + +**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. + +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 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. + +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. + +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. + + + + +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. "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. + +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: + +```bash +rm validators/placeholder.ak +``` + +Check that the project works: + +```bash +aiken check +``` + +It compiles and reports `0` tests, because the project is empty. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the tooling differs. + + + + +Your workspace: + +``` +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 +``` + +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. +- [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..8f9317128c --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/3-what-is-a-validator.md @@ -0,0 +1,170 @@ +--- +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 a transaction." +--- + +import Tabs from "@theme/Tabs"; +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. + +**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. + +## 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.** 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. + +## 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 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 + 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 the validator"] + 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. A script address has no key. Even the person who wrote the contract has to satisfy the rule like everyone else. + +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 + +**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. + +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. 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 + +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. + +Depending on the language you choose to write your validators in, you can see more or fewer arguments. + +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: + +- **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, 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. + + + + +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. + +```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 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. + +`_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. + +Run in your terminal: +```bash +aiken check +``` + +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: + +```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`**. 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: + +- **`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. + +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 + +- [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..535272b34a --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/4-datum-and-redeemer.md @@ -0,0 +1,186 @@ +--- +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. + +- 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. + +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. + +```mermaid +sequenceDiagram + 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 +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 are those two types on-chain: + + + + + + {extractRegion(VaultSimple, "types")} + + +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. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +:::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 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 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 + +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**. +::: + +:::tip Datum for state, redeemer for action +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. + + + + +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](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")}\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: + + + {extractRegion(VaultSimple, "types")} + + +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 { + spend( + datum: Option, + _redeemer: VaultAction, + _own_ref: OutputReference, + _self: Transaction, + ) { + expect Some(VaultDatum { owner }) = datum + True + } + + else(_) { + fail + } +} +``` + +Save it, and: + +```bash +aiken check +``` + +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. +- `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. + +**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. `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)**. + +## 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..8d602d6d71 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/5-transaction-context.md @@ -0,0 +1,152 @@ +--- +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. + +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: + +| 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 | + +:::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. + +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 context for all validators + +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. + +## One field is the whole vault + +These are the spend validator's rules, which decide whether the transaction is accepted: + + + + + + {extractRegion(VaultSimple, "validator", "traces")} + + +`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. +::: + +`_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 [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. `_redeemer` and `_own_ref` are handed over and never used: the vault reads its datum, reads the transaction, and looks at nothing else. + +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 + +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**. 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. + +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.** 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.** `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. + +**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. **[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..2db6cb52e0 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/6-testing.md @@ -0,0 +1,206 @@ +--- +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 + +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. + +[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. + +The ways to check that a contract behaves as you expect, from cheapest to most accurate: + +```mermaid +flowchart LR + 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. + +## Unit tests + +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. 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. + + + + +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 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 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**. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## Tracing: reading a refusal + +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. + +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, 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. + +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: + + + {extractRegion(VaultSimple, "trace-example")} + + +`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. + +**Traces cost nothing on-chain.** `aiken build` strips them back out, so the compiled script is byte for byte the one you had before. + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +## Property tests + +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 **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. + +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**. 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. 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. + +## Try it + +**Prove the rule you just wrote.** Everything below runs from `on-chain/vault/`, where lecture 2 left you. + + + + +**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 check +``` + +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. + +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`. + +That one is scaffolding. The vault's own tests replace it. + +**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")} + + +`..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. + +**Read the rule the other way round.** Add one more test below the two: + + + {extractRegion(VaultSimple, "pipe")} + + +`|>` 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. Put the rule back. + +**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 above the datum types: + + + {extractRegion(VaultSimple, "simple-fuzz-import")} + + +Then this at the bottom of the file: + + + {extractRegion(VaultSimple, "simple-property")} + + +`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. + +**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). + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +**[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 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)**. + +## Go deeper + +- [Testing](/docs/developers/curriculum/smart-contracts/testing): the test runner, mock transactions, and property testing in depth. +- [Local testing](/docs/developers/curriculum/start-building/local-testing): an in-memory emulator or a private devnet, so a run costs milliseconds instead of a confirmation. +- [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..2df95c941e --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/7-parameters.md @@ -0,0 +1,181 @@ +--- +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"; +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 + +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)`. + +Of the values **you** supply, the useful way to tell them apart is **when the value is provided**: + +| | 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 | +| **redeemer** | spend time | the spending transaction | just send a different one | + +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. + +## A small contract that uses a parameter + + + + + + {extractRegion(GuesserAiken, "guesser")} + + + + + +A [Scalus](https://scalus.org/) version is coming soon. The idea is identical, only the language differs. + + + + +`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. + +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 + +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. + +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 admin keys, two separate services: + +```mermaid +flowchart LR + S["the vault
one source file"] -->|"compile
once"| C["`the contract with a **blank** + where admin goes + _no address yet_`"] + C -->|"fill it in:
admin = aaaa…aa"| A["`one script hash + **addr_test1wrzptf…**`"] + 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 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 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: 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. 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, test, and audit **once**, and each deployment only passes a different value in. + +:::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 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. +::: + +## What "filling the blank" actually involves + +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, 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** explains it. + +## Try it + +**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 `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 `admin` for `AdminUnlock`. + +Both branches ask `list.has`, so there is nothing new to import. + + + {extractRegion(VaultAiken, "vault")} + + +```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 an `admin` in front: + + + {`// was\n${extractRegion(VaultSimpleAiken, "spend-call")}\n// now\n${extractRegion(VaultAiken, "spend-call")}`} + + +Add the admin key beside `owner` and `stranger`, and a test for each side of the new door: + + + {extractRegion(VaultAiken, "admin-tests")} + + +Fix the three existing calls the same way, then run `aiken check` again. Five tests, five passes. + +`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: each customer's key for their own funds, and the company's admin key for all of them. + + + + +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. 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. + +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..78af35e72e --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/8-validator-purposes.md @@ -0,0 +1,225 @@ +--- +title: "Validator purposes" +sidebar_label: "Validator purposes" +description: "One validator can guard different things depending on its purpose: spending a UTxO, minting tokens, withdrawing rewards." +--- + +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_. 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: + +| 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 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, 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: + +- its **payment credential** (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 -->|"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** + guards reward withdrawals`"] +``` + +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. + +## Your vault declares only one purpose + +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: + + + + +```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. 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. + +## 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. + +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. + +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 + W["your wallet
5 ADA"] --> T + + subgraph T["one transaction"] + direction TB + 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 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 + +**Write a minting policy.** A second validator, beside the vault. + + + + +Everything below runs from `on-chain/vault/`, where lecture 2 left you. + +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-dict")}\n${extractRegion(VaultAiken, "import-policy-id")}`} + + +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. Write it as a **second validator**, below the vault: + + + {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. + +`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. + +```bash +aiken check +aiken build +``` + +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 the policy's with ours: + +``` +32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0 +``` + +This one you should match exactly: there is no blank to fill, so nothing about your setup can move it. + +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 admin 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 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: + +- Unlock vested funds after a deadline: **spend** +- Create a one-of-a-kind NFT: **mint** +- Claim your staking rewards: **withdraw** + +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` validator is new, so it needs its own tests, and they are written exactly like the ones you already have. + + + + +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 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 script beside the vault and you know for certain you did not disturb it. + +**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. + + + + +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 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, 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 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. + +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..bc9356b511 --- /dev/null +++ b/docs/developers/onboarding/lectures/intermediate/9-frontend-integration.md @@ -0,0 +1,725 @@ +--- +title: "Off-chain and frontend integration" +sidebar_label: "Frontend integration" +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"; +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 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 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.** 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 + +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. + +## 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 `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. +- 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. + +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. + +## 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: + +- 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**. 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 +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 browser half + +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 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 + +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: 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 + +```mermaid +sequenceDiagram + participant App as Your app
(the browser, no secrets) + 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) + 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->>W: the 5 ADA comes back +``` + +## Try it + +**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 ../../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/off-chain/`, the way every `aiken` command ran from `on-chain/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 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). + +One more file, so your editor understands the code you are about to write. Create `tsconfig.json` beside `package.json`: + + + {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. + +### 2. From blueprint to address + +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"`, 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. + +:::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. +::: + +### 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 `src/lib/datum.ts`: + + + {extractRegion(Datum, "file")} + + +`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 transactions + +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. + +#### Mint the token + +```mermaid +flowchart LR + subgraph IN["INPUTS: UTxOs spent"] + I["`**your UTxO** + address: you + 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.2 ADA + no script runs`"}} + + subgraph OUT["OUTPUTS: UTxOs created"] + O1["`**locked** + address: the vault + value: 5 ADA + 3 TOKEN A + datum: owner = your key hash`"] + O2["`**change** + address: you + value: 4.5 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. + +**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 `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" }, +]); +``` + + + + +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 `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 + 3 TOKEN A + datum: owner = your key hash`"] + I2["`**your UTxO** + address: you + value: 4.5 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.15 ADA + 3 TOKEN A`"] + 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 `src/lib/unlock.ts`: + + + {extractRegion(UnlockLib, "file")} + + +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. + + + + +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. + + + + +#### 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 +``` + +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 `src/lib/mint.ts`: + + + {extractRegion(MintLib, "file")} + + +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. + + + + +An [Evolution](https://github.com/IntersectMBO/evolution-sdk) version is coming soon. + + + + + + + +### 5. Integration testing + +**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 `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 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 `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. + +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. 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. + +### 6. The key, and where it lives + +So the key gets its own file, which the page never reads. + +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 +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. + +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 + +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 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. `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" + + + + + My vault + + +
+ + + +``` + +And `vite.config.ts` beside it, which carries the proxy rule from the step before: + +```ts title="vite.config.ts" +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; + +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 }, + }; +}); +``` + +`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/…`. + +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. 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 `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 `src/app.tsx`: + + + {extractRegion(Minimal, "file")} + + +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"); +``` + +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 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. +- `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. **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. + +**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: + +```bash +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. 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. + +
+ + +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)**. + +## 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 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. + +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. +- **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. +- [Local testing](/docs/developers/curriculum/start-building/local-testing): an in-memory emulator or a private devnet to build against, instead of Preview. +- [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..75d8cc75b6 100644 --- a/docs/developers/onboarding/lectures/intermediate/introduction.md +++ b/docs/developers/onboarding/lectures/intermediate/introduction.md @@ -1,5 +1,137 @@ --- -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. + +:::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. +::: + +## What you'll be able to do + +- 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)**: 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. + +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: + +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 projects you'll build + +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. + +## 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, etc. + +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. + +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. + + + + +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. + +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. + +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/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 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/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 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..c12a6dadc3 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/blueprints/vault.plutus.json @@ -0,0 +1,173 @@ +{ + "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": "admin", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" + }, + { + "title": "vault.vault.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "admin", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" + }, + { + "title": "vault.vault_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" + }, + { + "title": "vault.vault_policy.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" + }, + { + "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." + }, + "Int": { + "dataType": "integer" + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "vault/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "AdminUnlock", + "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" + } + ] + } + ] + } + } +} 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/src/app.tsx b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx new file mode 100644 index 0000000000..f8383124e2 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/app.tsx @@ -0,0 +1,144 @@ +// 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 admin door and some styling. Both drive the same +// `./lib`, which is the code the reader writes. +// #region file +/// 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"; +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 { TOKEN_NAME, buildTokenTx, fetchTokenBalance, tokenUnit } from "./lib/token.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(""); + const [qty, setQty] = useState("1"); + const [tokens, setTokens] = useState("0"); + + 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)); + setTokens(await fetchTokenBalance(connected)); + } + + // 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}`); + } + } + + const FIVE_ADA = { unit: "lovelace", quantity: "5000000" }; + + function lock() { + 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 + // **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")); + } + + function unlock(utxo: UTxO) { + run(() => buildUnlockTx(wallet!, provider, utxo, provider)); + } + + if (!wallet) return ; + + return ( +
+

The vault lives at {vaultAddress(NETWORK_ID)}

+ + + + +

+ You hold {tokens} {TOKEN_NAME} +

+ + + + +
    + {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..c14e8ce2b0 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/check.ts @@ -0,0 +1,29 @@ +// 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"; + +// 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/admin.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/admin.ts new file mode 100644 index 0000000000..6f08c53fc0 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/admin.ts @@ -0,0 +1,67 @@ +// #region file +import { MeshTxBuilder, deserializeAddress } from "@meshsdk/core"; +import type { IEvaluator, IFetcher, IWallet, UTxO } from "@meshsdk/core"; + +import { vaultScriptCbor } from "./blueprint.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 buildAdminUnlockTx( + 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 *admin* wallet's key hash. Whichever wallet you hand in is the one + // whose signature this asks for. + 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) { + 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 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(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, + 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/blueprint.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/blueprint.ts new file mode 100644 index 0000000000..d67eee828f --- /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. +// #replace ../../blueprints/vault.plutus.json -> ../../../on-chain/vault/plutus.json +import blueprint from "../../blueprints/vault.plutus.json" with { type: "json" }; + +export { blueprint }; + +// #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"; + +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 admin key built into it. +// #region params +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 +/// 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( + { code: vaultScriptCbor, version: PLUTUS_VERSION }, + undefined, + networkId, + ).address; +} + +/// 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/datum.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts new file mode 100644 index 0000000000..50998560a2 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/datum.ts @@ -0,0 +1,17 @@ +// #region file +import { mConStr0, mConStr1 } from "@meshsdk/core"; +import type { Data } from "@meshsdk/core"; + +/// The datum: who owns the locked UTxO. +export function vaultDatum(ownerPubKeyHash: string): Data { + return mConStr0([ownerPubKeyHash]); +} + +/// The redeemer for `Unlock`. +export const unlockRedeemer: Data = mConStr0([]); + +// #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/fetch.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts new file mode 100644 index 0000000000..0d927add67 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/fetch.ts @@ -0,0 +1,26 @@ +// #region file +import { deserializeDatum } from "@meshsdk/core"; +import type { IFetcher, UTxO } from "@meshsdk/core"; + +import { vaultAddress } from "./blueprint.ts"; + +/// 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); + } catch { + return undefined; + } +} + +/// The UTxOs locked at the contract that name **you** as the owner. +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..0f6214ef5b --- /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 { Asset, IFetcher, IWallet } from "@meshsdk/core"; + +import { vaultAddress } from "./blueprint.ts"; +import { vaultDatum } from "./datum.ts"; + +/// Build a transaction that **locks** `assets` at the contract's address, with a +/// datum naming the connected wallet as the owner. A UTxO holds a bundle, so +/// this takes the whole list: ADA on its own, or ADA and tokens together. +export async function buildLockTx( + wallet: IWallet, + provider: IFetcher, + networkId: number, + assets: Asset[], +): 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. Whatever is + // in this bundle is what comes back when the owner unlocks it. + .txOut(vaultAddress(networkId), assets) + // 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..9173d6cab9 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/mint.ts @@ -0,0 +1,71 @@ +// #region file +import { MeshTxBuilder, deserializeAddress, mConStr0, stringToHex } from "@meshsdk/core"; +import type { IFetcher, IWallet } from "@meshsdk/core"; + +import { vaultAddress, vaultTokenPolicyId, vaultTokenScriptCbor } from "./blueprint.ts"; +import { vaultDatum } from "./datum.ts"; +import { TOKEN_NAME } from "./token.ts"; + +/// Build a transaction that **mints one vault token and locks it**, together with +/// `lovelace`, at the vault's address. +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 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(TOKEN_NAME); + + const txBuilder = new MeshTxBuilder({ fetcher: provider }); + return await txBuilder +// #region mint-calls + // Everything that follows describes one Plutus V3 script minting. + .mintPlutusScriptV3() + // One token is all this transaction needs. The handler checks the name, + // not the amount. + .mint("1", policyId, tokenNameHex) + // 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 + // 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/token.ts b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/token.ts new file mode 100644 index 0000000000..21f60f0bae --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/token.ts @@ -0,0 +1,94 @@ +// #region file +import { MeshTxBuilder, mConStr0, stringToHex } from "@meshsdk/core"; +import type { IFetcher, IWallet } from "@meshsdk/core"; + +import { vaultTokenPolicyId, vaultTokenScriptCbor } from "./blueprint.ts"; + +/// The token name the policy allows, as the contract spells it. +export const TOKEN_NAME = "TOKEN A"; + +/// The token's **unit**: its policy id followed by its name in hex. That pair is +/// how the chain names one particular token, and every balance is filed under it. +export function tokenUnit(): string { + return vaultTokenPolicyId() + stringToHex(TOKEN_NAME); +} + +/// How many of the token this wallet holds right now. Nothing is built or sent +/// here, so this is a read, like `fetch.ts`. +export async function fetchTokenBalance(wallet: IWallet): Promise { + const balance = await wallet.getBalance(); + return balance.find((asset) => asset.unit === tokenUnit())?.quantity ?? "0"; +} + +/// Build a transaction that only changes how many tokens exist. A positive +/// `quantity` mints that many into your wallet, a negative one burns that many +/// out of it. The policy checks the name and ignores the amount, so the two +/// directions are one transaction with one sign changed. +export async function buildTokenTx( + wallet: IWallet, + provider: IFetcher, + quantity: string, +): Promise { + // Where the wallet wants anything left over sent back to. Minted tokens land + // here too, because nothing else in this transaction claims them. + const changeAddress = await wallet.getChangeAddress(); + // Minting runs a script, so this transaction needs a deposit. + 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.", + ); + } + + const policyId = vaultTokenPolicyId(); + // Token names travel as hex on the chain, so convert it once here. + const tokenNameHex = stringToHex(TOKEN_NAME); + const unit = tokenUnit(); + const burning = quantity.startsWith("-"); + const utxos = await wallet.getUtxos(); + + // Burning destroys tokens you already hold, so the transaction has to spend + // the UTxOs holding them. Minting has nothing to spend. + const holding = burning + ? utxos.filter((utxo) => utxo.output.amount.some((asset) => asset.unit === unit)) + : []; + if (burning && holding.length === 0) { + throw new Error(`no ${TOKEN_NAME} in this wallet to burn`); + } + + const txBuilder = new MeshTxBuilder({ fetcher: provider }); + for (const utxo of holding) { + txBuilder.txIn( + utxo.input.txHash, + utxo.input.outputIndex, + utxo.output.amount, + utxo.output.address, + ); + } + + return await txBuilder + // Everything that follows describes one Plutus V3 script minting. + .mintPlutusScriptV3() + // Positive creates tokens, negative destroys them. The policy allows any + // amount, so long as this is the only name minted under it. + .mint(quantity, policyId, tokenNameHex) + // 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([])) + // 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(utxos) + // 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..44855367e7 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/lib/unlock.ts @@ -0,0 +1,69 @@ +// #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 redeemer and the +/// transaction, and only lets the spend through if the owner signed. +/// +/// Pass an `evaluator` to run the validator here, before anything is sent. +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..70069777c8 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/main.tsx @@ -0,0 +1,274 @@ +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 { TOKEN_NAME, buildTokenTx, fetchTokenBalance, tokenUnit } from "./lib/token.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 +// `api/blockfrost/[...path].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 [qty, setQty] = useState("1"); + const [tokens, setTokens] = useState("0"); + + 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)); + setTokens(await fetchTokenBalance(connected)); + 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)); + } + + async function reloadTokens() { + if (wallet) setTokens(await fetchTokenBalance(wallet)); + } + + // 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"} + + + + {" "} + {" "} + {" "} + +

+ 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. +

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

+ 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. +

+
+ + + +
    + {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..62782eadef --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/src/vault.test.ts @@ -0,0 +1,270 @@ +// 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, + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, + OfflineFetcher, + deserializeAddress, + serializeData, +} from "@meshsdk/core"; +import type { Asset } from "@meshsdk/core"; +import { OfflineEvaluator } from "@meshsdk/core-csl"; +import { MeshWallet } from "@meshsdk/wallet"; + +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 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"; + +import { blueprint } from "./lib/blueprint.ts"; + +import { buildMintAndLockTx } from "./lib/mint.ts"; +import { buildAdminUnlockTx } from "./lib/admin.ts"; +import { TOKEN_NAME, buildTokenTx, fetchTokenBalance, tokenUnit } from "./lib/token.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); + // 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; +} + +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 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 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, [adminPubKeyHash]); + 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, FIVE_ADA); + assert.ok(unsignedTx.length > 0, "lock transaction should build"); +}); +// #endregion offline-lock + +test("lock: a UTxO can hold tokens as well as ADA", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + fund(fetcher, address); + // The tokens a standalone mint would have left in the wallet. + addUtxo(fetcher, address, [ + { unit: "lovelace", quantity: "5000000" }, + { unit: tokenUnit(), quantity: "3" }, + ]); + + // One output, one bundle. The vault's validator never looks at what is in it. + const unsignedTx = await buildLockTx(owner, fetcher, NETWORK, [ + ...FIVE_ADA, + { unit: tokenUnit(), quantity: "3" }, + ]); + assert.ok(unsignedTx.length > 0, "lock with tokens should build"); +}); + +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(); + 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"); +}); + +test("token: a transaction that only mints, with no vault output", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + fund(fetcher, await owner.getChangeAddress()); + + // Three at once. The policy checks the name and ignores the amount. + const unsignedTx = await buildTokenTx(owner, fetcher, "3"); + + const evaluator = new OfflineEvaluator(fetcher, "preview"); + const costs = await evaluator.evaluateTx(unsignedTx, [], []); + assert.ok(costs.length >= 1, "the mint handler should approve three tokens"); +}); + +test("token: burning spends the tokens the wallet holds", async () => { + const fetcher = newFetcher(); + const owner = await makeWallet(fetcher, OWNER); + const address = await owner.getChangeAddress(); + fund(fetcher, address); + + // A UTxO holding tokens, the way one would sit in the wallet after a mint. + // Burning has to spend it, which is the difference from minting. + addUtxo(fetcher, address, [ + { unit: "lovelace", quantity: "5000000" }, + { unit: tokenUnit(), quantity: "3" }, + ]); + + // What the page's "Refresh tokens" button reads. + assert.equal(await fetchTokenBalance(owner), "3", `the wallet should hold three ${TOKEN_NAME}`); + + const unsignedTx = await buildTokenTx(owner, fetcher, "-1"); + + const evaluator = new OfflineEvaluator(fetcher, "preview"); + const costs = await evaluator.evaluateTx(unsignedTx, [], []); + assert.ok(costs.length >= 1, "the mint handler should approve the burn"); +}); + +// #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 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("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(); + 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 buildAdminUnlockTx(owner, fetcher, locked); + + const evaluator = new OfflineEvaluator(fetcher, "preview"); + await assert.rejects( + () => evaluator.evaluateTx(unsignedTx, [], []), + "the validator should refuse an AdminUnlock signed by the owner", + ); +}); + +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 = adminVaultAddress(alice, NETWORK); + const bobsVault = adminVaultAddress(bob, NETWORK); + + // 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. + 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(adminVaultAddress(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..8c7aec6a30 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/tsconfig.json @@ -0,0 +1,21 @@ +// #region file +{ + "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"] +} +// #endregion file 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..5ed1cd7a0d --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/off-chain/mesh/vite.config.ts @@ -0,0 +1,49 @@ +import { resolve } from "node:path"; +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; + +// Mesh uses Node built-ins (Buffer, crypto, stream) in the browser, so we polyfill them. +// +// 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(({ 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"), + }, + }, + }, + }; +}); 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..2df31f75b9 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.lock @@ -0,0 +1,26 @@ +# 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 = "aiken-lang/fuzz" +version = "v2.2.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "v3.1.0" +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..a8be6a4d66 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/aiken.toml @@ -0,0 +1,23 @@ +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 = "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..bbb386e105 --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/plutus.json @@ -0,0 +1,214 @@ +{ + "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": "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": { + "title": "datum", + "schema": { + "$ref": "#/definitions/vault~1VaultDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/vault~1VaultAction" + } + }, + "parameters": [ + { + "title": "admin", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" + }, + { + "title": "vault.vault.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "admin", + "schema": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + } + ], + "compiledCode": "590121010100229800aba2aba1aab9faab9eaab9dab9a9bae002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc8a60022b30013001300b37540051332259800980198069baa0088998009bac3002300e375400c6eb8c040c038dd5180818071baa0048998009bac3002300e375400c01680608c03cc040c040c040c040c040c040c040c04000488c8cc00400400c896600200314a115980099b8f375c602400200714a313300200230130014038808a2c805260166ea801a601c0069112cc004c01000a2b3001300f37540130038b20208acc004cdc3a400400515980098079baa009801c5901045900d201a180618068009b8748000c024dd50014590070c020004c010dd5004452689b2b200401", + "hash": "5e30f431981846c811b38f89280d99963f23c8df9b71bd1266695ed4" + }, + { + "title": "vault.vault_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" + }, + { + "title": "vault.vault_policy.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "58c701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900018031baa001899192cc004c030006266e3d22107544f4b454e204100375c601060160031640286464660020026eacc030c034c034c034c034c028dd51806003912cc004006007132325980099b910060018acc004cdc7803000c4dd59806801401500b44cc010010c04000d00b1bae300b001300d0014030297adef6c60375c6012600e6ea80062c8028c01c004c01cc020004c01c004c00cdd5003c52689b2b200201", + "hash": "32cfa014c18bccdfc9a2a6b40c1995d078e6e910fca787fe8ffdd3a0" + }, + { + "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." + }, + "Int": { + "dataType": "integer" + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "vault/VaultAction": { + "title": "VaultAction", + "anyOf": [ + { + "title": "Unlock", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "AdminUnlock", + "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/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 new file mode 100644 index 0000000000..dc855be0ee --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault.ak @@ -0,0 +1,197 @@ +// #region import-dict +use aiken/collection/dict +// #endregion import-dict + +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use aiken/fuzz + +// #region import-policy-id +use cardano/assets.{PolicyId} +// #endregion import-policy-id + +use cardano/transaction.{OutputReference, Transaction} + +// 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 + 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 token_name: ByteArray = "TOKEN A" +// #endregion token-name + +// The vault the reader builds across the track: the owner's key for normal use, +// and the service's admin key welded in at build time. +// +// 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(admin: 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) + AdminUnlock -> list.has(self.extra_signatories, admin) + } + } + + 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 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 +validator vault_policy { + mint(_redeemer: Data, policy_id: PolicyId, self: Transaction) { + expect [Pair(name, _)] = assets.tokens(self.mint, policy_id) + |> dict.to_pairs() + name == token_name + } + + else(_) { + fail + } +} +// #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 admin pair in +// **parameters**, the mint trio in **validator purposes**. +// +// Each one copies `transaction.placeholder` and fills in only the field its rule +// 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 +const owner: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000001" + +const stranger: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000002" + +// #endregion spend-tests + +// 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 admin-tests + +// #region spend-tests +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 = + #"000000000000000000000000000000000000000000000000000000ff" +// #endregion mint-tests + +// #region spend-tests +test unlock_ok_when_the_owner_signs() { + let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } + // #region spend-call + vault.spend(admin, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) + // #endregion spend-call +} + +test unlock_fails_for_a_stranger() fail { + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [stranger], + } + vault.spend(admin, Some(VaultDatum { owner }), Unlock, dummy_ref, tx) +} +// #endregion spend-tests + +// #region admin-tests +test admin_unlock_ok_when_the_admin_signs() { + let tx = Transaction { + ..transaction.placeholder, + extra_signatories: [admin], + } + 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 `AdminUnlock` path, and vice versa. +test admin_unlock_fails_when_the_owner_signs() fail { + let tx = Transaction { ..transaction.placeholder, extra_signatories: [owner] } + vault.spend(admin, Some(VaultDatum { owner }), AdminUnlock, dummy_ref, tx) +} +// #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 +// 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 = Transaction { + ..transaction.placeholder, + extra_signatories: [any_owner], + } + vault.spend( + admin, + Some(VaultDatum { owner: any_owner }), + Unlock, + dummy_ref, + tx, + ) +} +// #endregion vault-property + +// #region mint-tests +test mint_ok_for_a_correctly_named_token() { + let tx = Transaction { + ..transaction.placeholder, + mint: assets.from_asset(policy, token_name, 1), + } + vault_policy.mint(Void, policy, tx) +} + +test burn_ok_for_a_correctly_named_token() { + let tx = Transaction { + ..transaction.placeholder, + mint: assets.from_asset(policy, token_name, -1), + } + vault_policy.mint(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 new file mode 100644 index 0000000000..cb05c8075a --- /dev/null +++ b/examples/onboarding/lectures/intermediate/vault/on-chain/aiken/validators/vault_simple.ak @@ -0,0 +1,143 @@ +// `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. + +use aiken/collection/list +// #region datum-imports +use aiken/crypto.{VerificationKeyHash} +// #endregion datum-imports + +// 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 import-transaction + +// 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. +// +// 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( + datum: Option, + _redeemer: VaultAction, + _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 +// 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. A key hash is 28 bytes, so any 28 bytes stand in for one. +const owner: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000001" + +const stranger: VerificationKeyHash = + #"00000000000000000000000000000000000000000000000000000002" + +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 = 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 +// asserts refusal: this passes when the validator rejects the transaction. +test unlock_fails_for_a_stranger() fail { + 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 +// 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 = 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 diff --git a/sidebars.js b/sidebars.js index 6566233ca1..faf8371899 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", ], }, { 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); }