Hello plu-ts
Run-trough video
Pre-requisites
All we need to build a Smart contract and interact with it is:
plu-ts
- some way to submit transactions.
Infact, plu-ts
allows you to write the smart contract and create transactions.
To submit the tranasction we will use the koios API, with a simple POST request to the submit endpoint; but we'll think about that later.
So for now our pre-requisites add up to:
plu-ts
(andnpm
to install it)- anything that can run javascript (server environment or browser, doesn't matter)
- an internet connection
Project set up
usign git
we clone a very simple template project:
git clone https://github.com/HarmonicLabs/plu-ts-starter.git
mv plu-ts-starter hello-pluts
cd hello-pluts
git remote remove origin
this gives us a simple project structure:
./hello-pluts
├── package.json
├── package-lock.json
├── Introduction
├── src
│ ├── contract.ts
│ ├── index.ts
│ ├── MyDatum
│ │ └── index.ts
│ └── MyRedeemer
│ └── index.ts
└── tsconfig.json
Now we only need to run npm install
to automatically add the plu-ts
library.
npm install
et voilà we are ready to start!
The contract
Template overview
If we now navigate to src/contract.ts
we see we have a very simple validator already!
import { Address, bool, compile, makeValidator, PaymentCredentials, pBool, pfn, Script, ScriptType, V2 } from "@harmoniclabs/plu-ts";
import MyDatum from "./MyDatum";
import MyRedeemer from "./MyRedeemer";
export const contract = pfn([
MyDatum.type,
MyRedeemer.type,
V2.PScriptContext.type
], bool)
(( datum, redeemer, ctx ) =>
// always suceeds
pBool( true )
);
///////////////////////////////////////////////////////////////////
// ------------------------------------------------------------- //
// ------------------------- utilities ------------------------- //
// ------------------------------------------------------------- //
///////////////////////////////////////////////////////////////////
export const untypedValidator = makeValidator( contract );
export const compiledContract = compile( untypedValidator );
export const script = new Script(
ScriptType.PlutusV2,
compiledContract
);
export const scriptMainnetAddr = new Address(
"mainnet",
new PaymentCredentials(
"script",
script.hash
)
);
export const scriptTestnetAddr = new Address(
"testnet",
new PaymentCredentials(
"script",
script.hash.clone()
)
);
export default contract;
Let's focus only on the contract for now;
this contract expects a MyDatum
, a MyRedeemer
and finally a PScriptContext
to validate a transaction.
All of the three above are just Struct
s
MyDatum
and MyRedeemer
are types defined by us respectively in src/MyDatum/index.ts
and src/MyRedeemer/index.ts
import { int, pstruct } from "@harmoniclabs/plu-ts";
// modify the Datum as you prefer
const MyDatum = pstruct({
Num: {
number: int
},
NoDatum: {}
});
export default MyDatum;
import { pstruct } from "@harmoniclabs/plu-ts";
// modify the Redeemer as you prefer
const MyRedeemer = pstruct({
Option1: {},
Option2: {}
});
export default MyRedeemer;
whereas PScriptContext
is a predefined data structure that is passed by the cardano-node
itself that will run our smart contract.
finally, the contract is used in src/index.ts
which is our entry point.
import { script } from "./contract";
console.log("validator compiled succesfully! 🎉\n");
console.log(
JSON.stringify(
script.toJson(),
undefined,
2
)
);
the index just imports script
from src/contract.ts
adn prints it out in the json form.
if we go back to src/contract.ts
we see that the script is obtained using the following steps:
- adapting the validator to the standard using
makeValidator
/* ... */
export const untypedValidator = makeValidator( contract );
export const compiledContract = compile( untypedValidator );
export const script = new Script(
ScriptType.PlutusV2,
compiledContract
);
/* ... */
- compiling the validator with
compile
/* ... */
export const untypedValidator = makeValidator( contract );
export const compiledContract = compile( untypedValidator );
export const script = new Script(
ScriptType.PlutusV2,
compiledContract
);
/* ... */
- wrapping it in a
Script
that can be used offchain
/* ... */
export const untypedValidator = makeValidator( contract );
export const compiledContract = compile( untypedValidator );
export const script = new Script(
ScriptType.PlutusV2,
compiledContract
);
/* ... */
that is all we need for now.
run the template
If we did every step of above correctly we should be able to run
npm run start
and the output should look like:
validator compiled succesfully! 🎉
{
"type": "PlutusScriptV2",
"description": "",
"cborHex": "56550100002225333573466644494400c0080045261601"
}
Well congratulations 🥳!
this is your first compiled smart contract 🎉!
But we won't stop here for sure!
Let's personalize this smart contract.
Hello plu-ts
We want to personalize the smart contract so that:
- it suceeds if the transaction is signed by us.
- and we are being polite by saluting the contract.
introduce an owner
To make sure the transaction is signed by us we'll keep track of an owner
in the datum (the first argument we saw in the contract).
The datum helps us keep track of the history of the input the smart contract is validating.
Currently our datum is a struct, but it could be really anything;
and all we need to keep track of an owner is just a public key hash.
so in src/contract.ts
we'll change MyDatum
to PPubKeyHash
:
PPubKeyHash
is just an Alias
for bytestrings (of type bs
)
import { Address, bool, compile, makeValidator, PaymentCredentials, pBool, pfn, Script, ScriptType, V2 } from "@harmoniclabs/plu-ts";
import MyDatum from "./MyDatum";
import MyRedeemer from "./MyRedeemer";
const contract = pfn([
PPubKeyHash.type,
MyRedeemer.type,
V2.PScriptContext.type
], bool)
// we should also change the name of the variable here
// from `datum` to `owner`
(( owner, redeemer, ctx ) =>
// always suceeds
pBool( true )
);
/* ... */
send messages to the contracts
The second condtion requires us to send some message to the contract.
This is done thanks to the redeemer (or the second argument of a validator).
The redeemer is the argument specified by the user that interacts with the smart contract
once again, all we need in order to have a message is just a bytestring
, nothing more complex,
so we'll change MyRedeemer
to the primitive type bs
import { Address, bool, compile, makeValidator, PaymentCredentials, pBool, pfn, Script, ScriptType, V2 } from "@harmoniclabs/plu-ts";
import MyDatum from "./MyDatum";
import MyRedeemer from "./MyRedeemer";
const contract = pfn([
PPubKeyHash.type,
bs,
V2.PScriptContext.type
], bool)
// we should also change the name of the variable here
// from `redeemer` to `message`
(( owner, message, ctx ) =>
// always suceeds
pBool( true )
);
/* ... */
implement the logic
finally we'll check both the conditions in the body of the function.
so we'll first create a term that checks that the message is the one expected:
const isBeingPolite = message.eq("Hello plu-ts");
then we'll check that the transaction is signed by the owner specified in the datum.
to do so we need informations about the tranasaction and who signed it.
all the informations about the tranasaction are in the tx
field of the PScriptContext
an in particular we are interested in the signatories
field
ctx.tx.signatories;
since this is a list of all the required singers we chan use all the TermList
methods;
of which some
allows us to check that at leat one element of the list respects a given property:
const signedByOwner = ctx.tx.signatories.some( owner.eqTerm );
and finally, we put all together
/* ... */
const contract = pfn([
PPubKeyHash.type,
bs,
V2.PScriptContext.type
], bool)
(( owner, message, ctx ) => {
const isBeingPolite = message.eq("Hello plu-ts");
const signedByOwner = ctx.tx.signatories.some( owner.eqTerm );
return isBeingPolite.and( signedByOwner );
});
/* ... */
now runing the program with npm run start
gives us back:
validator compiled succesfully! 🎉
{
"type": "PlutusScriptV2",
"description": "",
"cborHex": "58fd58fb0100003232323232323232323232222533357346664446600e66e3c00922010c48656c6c6f20706c752d74730013300823371e00200866014002464660180024640026601ceb8dd6180a0009aba1001375c0066eb800800452616225333573400400229408cc01c852891119802980200109801800912999ab9a00214a20024460026aae78dd5001119801000a5eb108c0088d5d01801000911980190801111198028011801800980091111919980398020009801800801198020018011191801119801001000918011198010010009112999aab9f001003133002357420026ae880048d5d09aba2357446ae88d5d11aba2357446ae88d5d100081"
}
We did it! We wrote our first contract!
Deployng the Contract
now that we have our personal contract we'll use [Koios] to help us with the offchain.
for better integration we can install the koios-pluts
package so that we can make requests to koios and have actual plu-ts values.
npm install @harmoniclabs/koios-pluts
eventually we'll also need some help with binary data
npm install @harmoniclabs/uint8array-utils
then to keep the project clean we'll create a new offchain
folder, under src
mkdir src/offchain
Create the Koios provider
To make sure we do all our requests in testnet koios-pluts
exposes the utility class KoiosProvoder
which will keep in mind the network we are operating in for us.
let's build an instance:
import { KoiosProvider } from "@harmoniclabs/koios-pluts"
export const koios = new KoiosProvider("testnet");
export default koios;
Create the transaction builder
To build a TxBuilder
we'll need to fetch the current protocol parameters, that is an asyncronous operation;
so we'll write an asnyc function that constructs our transaction builder:
import { koios } from "./koios"
/**
* we don't want to do too many API call if we already have our `txBuilder`
*
* so after the first call we'll store a copy here.
**/
let _cachedTxBuilder: TxBuilder | undefined = undefined
export default async function getTxBuilder(): Promise<TxBuilder>
{
if(!( _cachedTxBuilder instanceof TxBuilder ))
_cachedTxBuilder = new TxBuilder(
"testnet",
await koios.epoch.protocolParams() // defaults to current epoch
);
return _cachedTxBuilder;
}
get some founds
You can use the Cardano Testnet Faucet.
Just be sure to select the Preprod testnet.
cardano-cli
and address generationIf you are in a server environment (such as NodeJS, Deno or Bun) you can generate a testnet address by frist running
cardano-cli address key-gen \
--verification-key-file path/to/pubKey.vkey \
--signing-key-file path/to/privKey.skey
and then using the verification key (public key) to generate an address.
This can be done both using cardano-cli
or using plu-ts
itself
- plu-ts
- cardano-cli
const myTestnetAddress = new Address(
"testnet",
new PaymentCredentials(
"pubKey",
PublicKey.fromCbor(
JSON.parse( // the result of `cardano-cli` is a json file
readFileSync(
"path/to/pubKey.vkey",
{ encoding: "utf8" }
)
).cborHex
).hash
)
)
cardano-cli address build \
--payment-verification-key-file path/to/pubKey.vkey \
--testnet-magic 1
once you finish with your tADA make sure to return them to the faucet.
tADA have no real world value but are still limited, and onther developers will need them!
to return tADA to the faucet just send them to the following testnet address:
addr_test1qqr585tvlc7ylnqvz8pyqwauzrdu0mxag3m7q56grgmgu7sxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknswgndm3
build the deploy transaction
now that we have received our tADA
we can start playng.
Firs we need to access them in our code.
We can do so by querying the utxos at the address you received the tADA to.
since we'll query our utxos some times we make an utility function for that.
import type { UTxO } from "@harmoniclabs/plu-ts"
import { koios } from "./koios";
export default async function queryMyUtxos(): Promise<UTxO[]>
{
return await koios.address.utxos( "<paste your tesnet address here>" )
}
now that we can access our utxos we can finally start building transactions.
The first transaction we want to do is to deploy the smart contract and found it.
Let's check we got everything:
- smart contract
-
TxBuilder
- A way to comunicate with the blockchain
yes, we can go!
import { Address, Value, DataB, Script, Tx } from "@harmoniclabs/plu-ts"
import { scriptTestnetAddr } from "../contract";
import getTxBuilder from "./getTxBuilder";
import queryMyUtxos from "./queryMyUtxos";
export default async function getDeployAndFoundTx( script: Script ): Promise<Tx>
{
const txBuilder = await getTxBuilder();
const myUTxOs = await queryMyUtxos();
return txBuilder.buildSync({
inputs: [{ utxo: myUTxOs[0] }],
outputs: [
{ // output which holds the reference script
address: scriptTestnetAddr,
value: Value.lovelaces( 10_000_000 ),
// an utxo with no datum that sits
// that a script address (like in this case)
// is locked FOREVER
// this way no one will be able to "un-deploy" our smart contract
datum: undefined,
refScript: script
},
{ // output holding the founds that we'll spend later
address: scriptTestnetAddr,
value: Value.lovelaces( 10_000_000 ),
// remeber to include a datum
datum: new DataB(
// remember we set the datum to be the public key hash?
// we can extract it from the address as follows
// first create an address form the bech32 form
Address.fromString( "<paste your address here>" )
// then extract the pyament credential hash
.paymentCreds.hash.toBuffer()
)
}
],
// send everything left back to us
changeAddress: "<paste your address here>"
});
}
now that we have a Tx
we are just two steps away from it to be registered on-chain:
- sign it
- submit it
sign the deploy transaction
to sign a transaciton you'll need the private key of your address.
depending on your environment then there are two ways to sign it:
- Browser
- NodeJS / Server
In the browser we can use the CIP-0030 standard to sign the transaction.
the standard wants us to pass the CBOR of the transaction encoded as hex string.
that is not a problem because we can get it as follows:
tx.toCbor().toString()
it then returns a new CBOR encoded as hex string which represents the signature.
we can add the signature to our transaction as follows:
const witnessSet = TxWitnessSet.fromCbor(
"<CIP-0030 'signTx' result here>"
);
for(const vkeyWit of witnessSet.vkeyWitnesses)
{
tx.addVKeyWitness( vkeyWit )
}
so all together becomes
async function signWithBrowser( tx: Tx, cip30wallet: any ): void
{
const witnessSet = TxWitnessSet.fromCbor(
await cip30wallet.signTx(
tx.toCbor().toString()
)
);
for(const vkeyWit of witnessSet.vkeyWitnesses)
{
tx.addVKeyWitness( vkeyWit )
}
}
If we are in a server environmet is very likely we have our private key stored in some file.
so we can just read the private key from there.
Once we have teh private key we can then use it to sign the transaction; plu-ts
handles the cryptography
tx.signWith( privateKey )
and that's it
so the final code is:
function signWithServer( tx: Tx ): void
{
tx.signWith(
PrivateKey.fromCbor(
JSON.parse( // the result of `cardano-cli` is a json file
readFileSync(
"path/to/privKey.skey",
{ encoding: "utf8" }
)
).cborHex
)
);
}
submit the deploy transaction
now we can finally deploy the smart contract all we need to do is just call the koios endpoint
/* onther imports */
import { koios } from "./offchain/koios"
/* ... */
async function main()
{
let tx = await getDeployAndFoundTx( script );
signWithServer( tx );
await koios.tx.submit( tx );
}
main();
Using the Contract
The last step is to build a new trasaction that will allow us to spend the founds we sent to the contract.
get the reference UTxO
Apart for this step the process very similar.
If you remember we deployed our script to an UTxO that is locked forever.
We need that UTxO to interact with the contract.
we could query but we already have all the infos to build it ourselves.
an UTxO
is composed by a TxOutRef
and a TxOut
the TxOutRef is just the hash of the
Tx` that generated it and the index in the order of the outputs.
so our TxOutRef
is:
const txOutRef = new TxOutRef({
id: tx.hash,
index: 0
});
and the TxOut
is the resolved reference, and we know exactly what's on that utxo:
const txOut = new TxOut({
address: scriptTestnetAddr,
value: Value.lovelaces( 10_000_000 ),
refScript: script
});
so our reference utxo is
const myRefUtxo = new UTxO({
utxoRef: txOutRef,
resolved: txOut
});
build the transaction
with that utxo reference we can build our tranasction
import { DataB, isData, Hash32, Tx, UTxO } from "@harmoniclabs/plu-ts"
import { scriptTestnetAddr } from "../contract";
import * as uint8array from "@harmoniclabs/uint8array-utils";
import koios from "./koios";
import getTxBuilder from "./getTxBuilder";
import queryMyUtxos from "./queryMyUtxos";
export default async function getSpendPoliteTx( myRefUtxo: UTxO ): Promise<Tx>
{
const txBuilder = await getTxBuilder();
const myUTxOs = await queryMyUtxos();
// find the other utxo of the previous tx
const utxoToSpend = (await koios.address.utxos( scriptTestnetAddr ))
.find( utxo => isData( utxo.resolved.datum ) );
if( utxoToSpend === undefined )
{
throw "uopsie, are you sure your tx had enough time to get to the blockchain?"
}
return txBuilder.buildSync({
inputs: [
{
utxo: utxoToSpend,
// we must include the utxo that holds our script
referenceScriptV2: {
refUtxo: myRefUtxo,
datum: "inline", // the datum is present already on `utxoToSpend`
redeemer: new DataB( uint8array.fromAscii("Hello plu-ts") ) // be polite
}
}
],
// make sure to include collateral when using contracts
collaterals: [ myUTxOs[0] ],
// send everything back to us
changeAddress: "addr_test1vpv03vsr8mtgu7sftu82x0y3nmv4fs6xnkw5jvrkw3luw3ck4hmfa"
});
}
sign and submit
We can re use the fuctions defined above for both signing and submission.
so all we need to do now is really just put everything together
import { script, scriptTestnetAddr } from "./contract";
import { koios } from "./offchain/koios";
/* ... */
async main()
{
let tx = await getDeployAndFoundTx( script );
await signTxServer( tx );
await koios.tx.submit( tx );
console.log( "waiting for tx '" + tx.hash.toString() + "' to be on-chain...")
await koios.tx.waitConfirmed( tx );
const myRefUtxo = new UTxO({
utxoRef: new TxOutRef({
id: tx.hash,
index: 0
}),
resolved: new TxOut({
address: scriptTestnetAddr,
value: Value.lovelaces( 10_000_000 ),
refScript: script
})
});
tx = await getSpendPoliteTx( myRefUtxo )
signWithServer( tx );
await koios.tx.submit( tx );
console.log( "waiting for tx '" + tx.hash.toString() + "' to be on-chain...\n\n")
await koios.tx.waitConfirmed( tx );
console.log(
`Unlocked ${
tx.body.inputs[0].resolved.value.lovelaces / BigInt(1_000_00)
} tADA ` +
`from ${scriptTestnetAddr.toString()}!\n\n` +
`Check the transaction on Cardanoscan: https://preview.cardanoscan.io/transaction/${tx.hash.toString()}?tab=contracts`
)
}
main();
If everything whent trough correctly running the program wiht npm run start
should now show something like:
Unlocked 10 tADA from addr_test1vabcd... !
Check the transaction on Cardanoscan: https://preview.cardanoscan.io/transaction/beefcaffee...?tab=contracts
Return the tADA
When you are done playing the testnet be sure to return your tADA
to the faucet.
Here, as a bonus you can build the transaction yourself!
const returnTADA = txBuilder.buildSync({
inputs:
(await koios.address.utxos("<paste your address here>"))
.map( utxo => ({ utxo }) ) // wrap in the expected input format
// the faucet address
changeAddress: "addr_test1qqr585tvlc7ylnqvz8pyqwauzrdu0mxag3m7q56grgmgu7sxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknswgndm3"
})