Vesting
Now that we are a bit more familiar on how to interact properly with a smart contract, let's do a step forward and see if we can come up with a slightly more complex contract.
The final result can be found at HarmonicLabs/vesting-pluts
.
Check package.json
file at HarmonicLabs/vesting-pluts
for versions of packages used lately.
Project set up
We will once again start from the plu-ts-starter
template (with the plu-ts version ~0.8.2) :
git clone https://github.com/HarmonicLabs/plu-ts-starter.git
mv plu-ts-starter vesting-pluts
cd vesting-pluts
git remote remove origin
this gives us a simple project structure:
./vesting-pluts
├── package.json
├── package-lock.json
├── Introduction
├── src
│ ├── contract.ts
│ ├── index.ts
│ ├── MyDatum
│ │ └── index.ts
│ └── MyRedeemer
│ └── index.ts
└── tsconfig.json
Add dependencies
Just like the Hello plu-ts example; this project already comes with plu-ts as dependency; all we need to do to then is to run
npm install
This time instead of working with Browser wallets, we will generate key pairs using Web Crypto API and thus generate address.
We will also be making use of Blockfrost API to interact with the contract, address, transaction on/to-be-on chain (enabled with the package @harmoniclabs/blockfrost-pluts
)
Template overview
Before we dive in let's get familiar with the starter template.
If we now navigate to src/contract.ts
we see we have a very simple validator already!
import { Address, compile, Credential, pfn, Script, ScriptType, PScriptContext, unit, passert } from "@harmoniclabs/plu-ts";
export const contract = pfn([
PScriptContext.type
], unit )
(( { redemeer, tx, purpose } ) => {
// always succeeds
return passert.$(true)
});
///////////////////////////////////////////////////////////////////
// ------------------------------------------------------------- //
// ------------------------- utilities ------------------------- //
// ------------------------------------------------------------- //
///////////////////////////////////////////////////////////////////
export const compiledContract = compile( contract );
export const script = new Script(
ScriptType.PlutusV3,
compiledContract
);
export const scriptMainnetAddr = new Address(
"mainnet",
Credential.script(
script.hash
)
);
export const scriptTestnetAddr = new Address(
"testnet",
Credential.script(
script.hash.clone()
)
);
export default contract;
Let's focus only on the contract for now.
As per the latest Plutus V3, this contract expects a mandatory PScriptContext
to validate a transaction.
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 successfully! 🎉\n");
console.log(
JSON.stringify(
script.toJson(),
undefined,
2
)
);
The index just imports script
from src/contract.ts
and 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:
- Compiling the validator with
compile
export const compiledContract = compile( contract );
export const script = new Script(
ScriptType.PlutusV3,
compiledContract
);
- Wrapping it in a
Script
that can be used offchain
export const compiledContract = compile( contract );
export const script = new Script(
ScriptType.PlutusV3,
compiledContract
);
That is all we need for now.
Run the template
If we did every step above correctly, we should be able to run
npm run start
and the output should look like:
validator compiled successfully! 🎉
{
"type": "PlutusScriptV3",
"description": "",
"cborHex": "515001010023259800800c5268b2ae689441"
}
Well congratulations 🥳!
This is your first compiled smart contract 🎉!
But we won't stop here for sure!
Let's personalize this smart contract.
The contract
The contract should succeed if the following two conditions are met:
- the transaction is signed by the
PPubKeyHash
defined in the UTxO datum; - the transaction lower bound is
Finite
and greater than the datumdeadline
field;
VestingDatum
The first thing we notice is that we need a custom datum.
Let's create a folder VestingDatum
with index.ts
file to define the types beneficiary
and deadline
in the contract should hold.
Now, modify src/contract.ts
as follows
import { PPubKeyHash, int, pstruct } from "@harmoniclabs/plu-ts";
// modify the Datum as you prefer
const VestingDatum = pstruct({
VestingDatum: {
beneficiary: PPubKeyHash.type,
deadline: int // posix time
}
});
export default VestingDatum;
Contract signature
There is no change in the contract signature, as all details about the transaction and the purpose of the script within the transaction are embedded within PScriptContext
.
/* imports */
export const contract = pfn([
PScriptContext.type
], unit)
(( {redeemer, tx, purpose} ) => {
/* contract logic */
});
/* other code */
Contract logic
We know for sure that we need 2 conditions. So we will check them separately using two terms- signedByBeneficiary
and deadlineReached
.
Now that we have our datum structure, we can use it in the contract definition.
/* imports */
export const contract = pfn([
PScriptContext.type
], unit)
(( {redeemer, tx, purpose} ) => {
const maybeDatum = plet(
pmatch(purpose)
.onSpending(({ datum }) => datum)
._(_ => perror(PMaybe(data).type))
);
const datum = plet( punsafeConvertType( maybeDatum.unwrap, VestingDatum.type ) )
const signedByBeneficiary = tx.signatories.some( datum.beneficiary.eq )
// inlined
const deadlineReached = plet(
pmatch( tx.interval.from.bound )
.onPFinite(({ n: lowerInterval }) =>
datum.deadline.ltEq( lowerInterval )
)
._( _ => pBool( false ) )
)
return passert.$(
(ptraceIfFalse.$(pdelay(pStr("Error in signedByBenificiary"))).$(signedByBeneficiary))
.and( ptraceIfFalse.$(pdelay(pStr("deadline not reached or not specified"))).$( deadlineReached ) )
);
});
/* other code */
We just initialize them to pBool( false )
so that if we forget them the contract fails.
But we can already see the structure of the contract this way: we have two conditions, and we want both to be true.
// inlined
for?As defined above the terms are inlined every time that are used.
This is because we are not using plet
to create an actual plu-ts
variable. Instead we are just holding a reference to that piece of code.
This is not necessarily bad because it helps making the contract more readable (and plet
would have inlined the term anyway in this particular case for efficiency).
But, it is definitely useful to keep in mind that what we have is always inlined with a small comment.
signedByBeneficiary
The first condition for the contract to succeed is:
the transaction is signed by the
PPubKeyHash
defined in the UTxO datum;
To check that we can use the signatories
field defined in the PTxInfo
struct.
We can access the field from the transaction using the dot notation:
tx.signatories
The signatories
field is a list of PPubKeyHash
; so we have access to all the TermList
methods.
so we can use the some
method to check that at least one element of the list satisfies a given predicate.
In our case:
tx.signatories.some( signer => signer.eq( datum.beneficiary ) );
Or the equivalent (but slightly more efficient)
tx.signatories.some( datum.beneficiary.eq );
Here, we would define the beneficiary
and deadline
in datum
of the spending transaction(in src/app/createVesting.ts
).
To use them, extract the maybeDatum
from purpose
, and then properly type convert to ultimately get datum
:
const maybeDatum = plet(
pmatch(purpose)
.onSpending(({ datum }) => datum)
._(_ => perror(PMaybe(data).type))
);
const datum = plet( punsafeConvertType( maybeDatum.unwrap, VestingDatum.type ) )
And that's it!
Our signedByBeneficiary
condition is ready
const maybeDatum = plet(
pmatch(purpose)
.onSpending(({ datum }) => datum)
._(_ => perror(PMaybe(data).type))
);
const datum = plet( punsafeConvertType( maybeDatum.unwrap, VestingDatum.type ) )
const signedByBeneficiary = tx.signatories.some( datum.beneficiary.eq )
deadlineReached
Now we can pass to the second condition:
the transaction lower bound is
Finite
and greater than the datumdeadline
field
That is due to how time is handled on-chain.
Handling time on chain is definitely not something simple due to the fact that the underlying system is distributed.
That means that we can't really be 100% sure of the exact moment the script is executed.
To work around this problem, time is represented on chain with a range in which the transaction is considered valid.
If ever the transaction where sent outside of the range, it would be rejected by the node before even executing the script!
So we can at least be sure that the script is executed in the given time range.
We can access the transaction validity time range trough the interval
property of the PTxInfo
struct.
This is done once again using the dot notation:
tx.interval
The interval
type is somewhat complex due to the nested structure; we have
- two initial properties (
from
andto
) representing the lower and upper bound. - both the properties then have a
bound
property and aninclusive
property which is a boolean (of the two we are only interested in thebound
one) - finally the
bound
has 3 constructors as below
const PExtended = pstruct({
PNegInf: {},
PFinite: { n: int },
PPosInf: {}
});
where PFinite
is the one we are interested in.
So reaching the bound
field is the easy part and can be done as follows:
tx.interval.from.bound
But then we have to use pmatch
to understand what constructor was used.
In particular, we are only interested in the PFinite
one. So we'll use the underscore (_
) wildcard to match the other two.
pmatch( tx.interval.from.bound )
.onPFinite(({ n: lowerInterval }) =>
/* deadline condition */
)
._( _ => pBool( false ) )
Now that we have access to the transaction lower bound, we can finally check for the deadline to have been passed.
datum.deadline.ltEq( lowerInterval )
The final deadlineReached
condition becomes:
// inlined
const deadlineReached = plet(
pmatch( tx.interval.from.bound )
.onPFinite(({ n: lowerInterval }) =>
datum.deadline.ltEq( lowerInterval )
)
._( _ => pBool( false ) )
)
For debugging closely in case the contract fails due to deadlineReached
condition, and to throw a specific error in that case, we can add pTrace
to the return statement and modify it as:
return passert.$(
(ptraceIfFalse.$(pdelay(pStr("Error in signedByBenificiary"))).$(signedByBeneficiary))
.and( ptraceIfFalse.$(pdelay(pStr("deadline not reached or not specified"))).$( deadlineReached ) )
);
Compiling the contract
Our smart contract should now look something like this:
export const contract = pfn([
PScriptContext.type
], unit)
(( {redeemer, tx, purpose} ) => {
const maybeDatum = plet(
pmatch(purpose)
.onSpending(({ datum }) => datum)
._(_ => perror(PMaybe(data).type))
);
const datum = plet( punsafeConvertType( maybeDatum.unwrap, VestingDatum.type ) )
const signedByBeneficiary = tx.signatories.some( datum.beneficiary.eq )
// inlined
const deadlineReached = plet(
pmatch( tx.interval.from.bound )
.onPFinite(({ n: lowerInterval }) =>
datum.deadline.ltEq( lowerInterval )
)
._( _ => pBool( false ) )
)
return passert.$(
(ptraceIfFalse.$(pdelay(pStr("Error in signedByBenificiary"))).$(signedByBeneficiary))
.and( ptraceIfFalse.$(pdelay(pStr("deadline not reached or not specified"))).$( deadlineReached ) )
);
});
As we saw in the Hello plu-ts example project, we can compile the contract
by passing the term to the compile
function.
We finally pass the compiled Contract to the Script
constructor so that we can use it properly.
/* contract definition above */
export const compiledContract = compile( contract );
export const script = new Script(
ScriptType.PlutusV3,
compiledContract
);
/* some other code */
So now running the project using
npm run start
we should see something like this
validator compiled successfully! 🎉
{
"type": "PlutusScriptV3",
"description": "",
"cborHex": "59022d59022a0101003232323232323223232323259800800c5268b2ae686644b3001002800c5282ae6866016292011c4572726f7220696e207369676e6564427942656e69666963696172790033233009214a04446644b30010028a51800aae68600800426006002466e3cdd71aba10020013323323009235740600400200297ac475c6eb0c8d5d09aba2357446ae88d5d11aba2357446ae88d5d10008010998058a48125646561646c696e65206e6f742072656163686564206f72206e6f7420737065636966696564003298009aba1300735742600e646ae84d5d11aba2357446ae88d5d11aba235744002007001919b89375a600e0066eb4d5d0800c005222232332598009800a400110038acc004c00520028802456600260029002440162cab9a2ae68ab9a1b8735573a0026aae78004dd500209281802991aba135573c6ea8004ca6002646ae84d5d11aba2001003800c00600300191803000c005222222232332598009800a400110038acc004c00520028802456600260029002440162b300130014801a200d1598009800a401110078acc004c005200a8804459573455cd1573455cd1573455cd0dc39aab9d00135573c0026ea801c4c60086ae84004c00c0048d5d09aba2001235573c6ea800488cc00c84008888cc014008c00c004c0088888ca600260080033003001801200c3300400300222259800800c00e2660046ae84004d5d1000aaae7c46460044660040040024600446600400400244b30010018a51899ab9c50024a0ab9a1"
}
Setup Blockfrost API
First and foremost, we need to setup Blockfrost API to use in our app.
It is enabled by the package @harmoniclabs/blockfrost-pluts
. In your terminal, do
npm install @harmoniclabs/blockfrost-pluts
Go to https://blockfrost.io/ and create a new project for this example (a free plan is more than enough for now). Make sure to pick Preprod
as your preferred network.
Once done, navigate to the Dashboard and copy over PROJECT_ID to setup.
Now, create file blockfrost.ts
within src/app
:
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
function blockfrost () {
const provider = new BlockfrostPluts({
projectId: "Paste your PROJECT_ID here"
});
return provider;
}
export default blockfrost;
Make sure to not check-in your PROJECT_ID into any versioning systems. Alternatively, you can always use environment variables here.
To enable blockfrost within other files, we will instantiate blockfrost()
with the PROJECT_ID.
To perform transactions through Blockfrost, we need to instantiate our TxBuilder
with Blockfrost.
Create file getTxBuilder.ts
within src/app
, exporting method getTxBuilder()
which will thus be used to instantiate an instance of TxBuilder
and further used.
import { TxBuilder } from "@harmoniclabs/plu-ts";
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
/**
* 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(Blockfrost: BlockfrostPluts): Promise<TxBuilder>
{
if(!( _cachedTxBuilder instanceof TxBuilder ))
{
const parameters = await Blockfrost.getProtocolParameters();
_cachedTxBuilder = new TxBuilder(parameters);
}
return _cachedTxBuilder;
}
Interacting with the contract
We will use the native npm
script functionality to define some scripts to interact arbitrarily with our new contract.
We can define a new script by adding an entry in the scripts
field of the package.json
file that you find in the root of the project.
Right now you should see something like this:
"scripts": {
"build": "tsc -p ./tsconfig.json && tsc-alias -p ./tsconfig.json",
"start": "npm run build && node dist/index.js"
},
We can add our own scripts by specifying the script name as key and the command to execute as value.
As an example, say we want a better name for the "start"
script, we could do something like:
"scripts": {
"build": "tsc -p ./tsconfig.json && tsc-alias -p ./tsconfig.json",
"start": "npm run build && node dist/index.js",
"vesting:compile": "npm run start"
},
Now running
npm run vesting:compile
is equivalent to npm run start
.
To keep the project clean we'll create a new directory under src
called app
where all our scripts will be.
./vesting-pluts
└── src
└── app
Save the script
Now we can start working with the off-chain part of plu-ts.
Let's start by saving the compiled script to a file when we compile it.
In the src/index.ts
file add the following:
import { existsSync } from "fs";
import { mkdir, writeFile } from "fs/promises";
/* old code */
async function main()
{
if( !existsSync("./testnet") )
{
await mkdir("./testnet");
}
await writeFile("./testnet/vesting.plutus.json", JSON.stringify(script.toJson(), undefined, 4))
}
main();
Now running
npm run vesting:compile
should still print a success result; but it will also create a new testnet
directory with the file vesting.plutus.json
in it.
Get Addresses and their keys
Depending on if you are working in private or public testnet, there are 2 way to get your keys and start creating transactions.
- Public testnet
- Private testnet
You can generate your own key pair using generateKey()
method from Crypto Web API. Read more about this here.
Using this, to generate 2 pairs of keys, create a file genKeys.ts
in the app
folder that looks like this
import { existsSync } from "fs";
import { Address, Credential, PublicKey, PrivateKey, PubKeyHash } from "@harmoniclabs/plu-ts";
import { config } from "dotenv";
import { mkdir, writeFile } from "fs/promises";
import pkg from 'blakejs';
const { blake2b } = pkg;
config();
async function genKeys()
{
const nKeys = 2;
const promises: Promise<any>[] = [];
if( !existsSync("./testnet") )
{
await mkdir("./testnet");
}
for( let i = 1; i <= nKeys; i++ )
{
// generate public-private keypair
let keyPair = await globalThis.crypto.subtle.generateKey(
{
name: "Ed25519",
namedCurve: "Ed25519"
},
true,
["sign", "verify"]
);
// convert keyPair.(publicKey|privateKey)<CryptoKeyPair> ultimately to PublicKey which can be converted to cborString to store it for future reference
// Export public key in a way compatible to Cardano CLI
const publicKeyArrayBuffer = await globalThis.crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyUint8Array = new Uint8Array(publicKeyArrayBuffer);
const publicKey = new PublicKey(publicKeyUint8Array);
const publicKeyHash = new PubKeyHash(blake2b(publicKeyUint8Array, undefined, 28)); // to build Credential
const pubKeyJsonObj = {
type: "PaymentVerificationKeyShelley_ed25519",
description: "Payment Verification Key",
cborHex: publicKey.toCbor().toString()
}; // JSON structure similar to the one generated when by Cardano CLI
const pubKeyJsonStr = JSON.stringify(pubKeyJsonObj, null, 4);
await writeFile(`./testnet/payment${i}.vkey`, pubKeyJsonStr);
// Export of the private key in a way that's compatible with the Cardano CLI
const privateKeyArrayBuffer = await globalThis.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); // privateKey cannot be exported 'raw' hence 'pkcs8'
const privateKeyUint8Array = new Uint8Array(privateKeyArrayBuffer.slice(-32));
const privateKey = new PrivateKey(privateKeyUint8Array);
const pvtKeyJsonObj = {
type: "PaymentSigningKeyShelley_ed25519",
description: "Payment Signing Key",
cborHex: privateKey.toCbor().toString()
}; // JSON structure similar to the one generated when by Cardano CLI
const pvtKeyJsonStr = JSON.stringify(pvtKeyJsonObj, null, 4);
await writeFile(`./testnet/payment${i}.skey`, pvtKeyJsonStr);
// Check that the derivations went fine
const pubKeyfromPriv = privateKey.derivePublicKey();
if (pubKeyfromPriv.toString() !== publicKey.toString()) {
throw new Error("\tPublic key derivation from private key failed");
}
else {
console.log("\tPublic key derivation from private key succeeded");
}
// Create the address
const credential = Credential.keyHash(publicKeyHash);
const address = new Address("testnet", credential);
await writeFile(`./testnet/address${i}.addr`, address.toString());
}
// wait for all files to be copied
await Promise.all( promises );
}
genKeys();
Add a new npm
script vesting:genKeys
"scripts": {
// ...
"vesting:genKeys": "npm run build:light && node dist/app/genKeys.js"
}
so that running
npm run vesting:genKeys
should give us 2 pairs of keys and 2 addresses under the testnet
folder.
Remember to fund the addresses. In this example, make sure to fund address1 and address2; just so that there has been some transactions involving these addresses, and they appear on blockchain and inturn be visible for Blockfrost. Read more about such possible Blockfrost error here.
You can use the Cardano Testnet Faucet to get some testnet funds. Just be sure to select Preprod
testnet.
If you are working in the private testnet then you probably want to use some keys you already have.
Then maybe you can copy those keys in the testnet
folder we have here.
To do so we can set up a new setup.ts
script under the app
folder:
import { existsSync } from "fs";
import { config } from "dotenv";
import { copyFile, mkdir } from "fs/promises";
config();
async function setup()
{
const privateTestnet = process.env.PRIVATE_TESTNET_PATH ?? ".";
const nKeys = 3;
const promises: Promise<any>[] = [];
if( !existsSync("./testnet") )
{
await mkdir("./testnet");
}
for( let i = 1; i <= nKeys; i++ )
{
promises.push(
copyFile(`${privateTestnet}/addresses/payment${i}.addr`, `./testnet/address${i}.addr`),
copyFile(`${privateTestnet}/stake-delegator-keys/payment${i}.vkey`, `./testnet/payment${i}.vkey`),
copyFile(`${privateTestnet}/stake-delegator-keys/payment${i}.skey`, `./testnet/payment${i}.skey`)
);
}
// wait for all files to be copied
await Promise.all( promises );
}
setup();
And then include a new npm script
in package.json
"scripts": {
// ...
"vesting:setup": "npm run build:light && node dist/app/setup.js"
}
so that you can now run
npm run vesting:setup
to have your keys and addresses copied in the testnet
folder.
Create a Vesting UTxO
Now we can finally start playing around with the vesting contract.
Read the script
Since we already have our file compiled and saved, it is probably a good idea to read the compiled result instead of re-compiling the contract each time we run the script.
To do so, we can always read the saved Script
. First we need to read the vesting.plutus.json
file as string.
const scriptFile = await readFile("./testnet/vesting.plutus.json", { encoding: "utf-8" });
Then, to finally retrieve the Script
from the cborHex
in the JSON file:
const script = Script.fromCbor(JSON.parse(scriptFile).cborHex, ScriptType.PlutusV3)
Make sure to specify ScriptType.PlutusV3
as second argument to Script.fromCbor
even though it is optional, because the defType
arg is by default Script.PlutusV2
which being legacy.
From here we can generate the script address using the Address
class (from the offchain of plu-ts
) and the Script
as Credential
.
const scriptAddr = new Address(
"testnet",
new Credential(CredentialType.Script, script.hash)
);
Instead of new Address("testnet", ...)
, we can also do Address.testnet(...)
. Read more here.
Get your address
Now, to build and send our transaction we are just missing the sender key, address and the beneficiary public key.
Once again we retrieve them from our saved files using node.js readFile
.
const privateKeyFile = await readFile("./testnet/payment1.skey", { encoding: "utf-8" });
const privateKey = PrivateKey.fromCbor( JSON.parse(privateKeyFile).cborHex );
const addr = await readFile("./testnet/address1.addr", { encoding: "utf-8" });
const address = Address.fromString(addr);
const publicKeyFile = await readFile("./testnet/payment2.vkey", { encoding: "utf-8" });
const pkh = PublicKey.fromCbor( JSON.parse(publicKeyFile).cborHex ).hash;
Query the address UTxOs
Before we really start building our transaction we need some UTxOs to use as input. We can query the UTxOs available in our address through Blockfrost. Make sure to populate both the addresses with some funds.
const utxos = await Blockfrost.addressUtxos( address );
Instead of blindly assigning the first UTxO of the utxos[] as input, let us assure that we use the UTxO that has atleast 15 ada in it just to avoid possible failures.
const utxo = utxos.find(utxo => utxo.resolved.value.lovelaces >= 15_000_000)!;
Build the Transaciton
Our transaction will be constructed as follows:
- our UTxO as input
- an output to the contract with an attached
VestingDatum
- the change going back to the address
which translates to the following code
let tx = await txBuilder.buildSync({
inputs: [{ utxo: utxo }],
collaterals: [ utxo ],
outputs: [
{
address: scriptAddr,
value: Value.lovelaces( 10_000_000 ),
datum: VestingDatum.VestingDatum({
beneficiary: pBSToData.$( pByteString( pkh.toBuffer() ) ),
deadline: pIntToData.$( nowPosix + 10_000 )
})
}
],
changeAddress: address
});
The datum
attached to the output is generated using on-chain code!
This is done, thanks to plu-ts, being able to evaluate on-chain code and use the result as Data
.
This way we can use the on-chain types to describe the plutus data off-chain, without the need to use low level Data
elements!
Sign and Submit
Now that we have our transaction, all we need is just to Sign and Submit it.
And guess what?
This is now extremly easy with plu-ts
and Blockfrost
.
await tx.signWith( new PrivateKey(privateKey) );
const submittedTx = await Blockfrost.submitTx( tx );
All we need to do now is to put it all together in a file createVesting.ts
under the app
folder.
import { Address, Credential, Hash28, PrivateKey, Value, pBSToData, pByteString, pIntToData, CredentialType, PublicKey, Script, ScriptType } from "@harmoniclabs/plu-ts";
import VestingDatum from "../VestingDatum";
import getTxBuilder from "./getTxBuilder";
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
import blockfrost from "./blockfrost";
import { readFile } from "fs/promises";
async function createVesting(Blockfrost: BlockfrostPluts)
{
const txBuilder = await getTxBuilder(Blockfrost);
const scriptFile = await readFile("./testnet/vesting.plutus.json", { encoding: "utf-8" });
const script = Script.fromCbor(JSON.parse(scriptFile).cborHex, ScriptType.PlutusV3)
const scriptAddr = new Address(
"testnet",
new Credential(CredentialType.Script, script.hash)
);
const privateKeyFile = await readFile("./testnet/payment1.skey", { encoding: "utf-8" });
const privateKey = PrivateKey.fromCbor( JSON.parse(privateKeyFile).cborHex );
const addr = await readFile("./testnet/address1.addr", { encoding: "utf-8" });
const address = Address.fromString(addr);
const publicKeyFile = await readFile("./testnet/payment2.vkey", { encoding: "utf-8" });
const pkh = PublicKey.fromCbor( JSON.parse(publicKeyFile).cborHex ).hash;
const utxos = await Blockfrost.addressUtxos( address )
.catch( e => { throw new Error ("unable to find utxos at " + addr) })
// atleast has 10 ada
const utxo = utxos.find(utxo => utxo.resolved.value.lovelaces >= 15_000_000)!;
if (!utxo) {
throw new Error("No utxo with more than 10 ada");
}
const nowPosix = Date.now();
let tx = await txBuilder.buildSync({
inputs: [{ utxo: utxo }],
collaterals: [ utxo ],
outputs: [
{
address: scriptAddr,
value: Value.lovelaces( 10_000_000 ),
datum: VestingDatum.VestingDatum({
beneficiary: pBSToData.$( pByteString( pkh.toBuffer() ) ),
deadline: pIntToData.$( nowPosix + 10_000 )
})
}
],
changeAddress: address
});
await tx.signWith( new PrivateKey(privateKey) );
const submittedTx = await Blockfrost.submitTx( tx );
console.log(submittedTx);
}
if( process.argv[1].includes("createVesting") )
{
createVesting(blockfrost());
}
For the ease of use, let's add a new npm script
in package.json
"scripts": {
// ...
"vesting:create": "npm run build:light && node dist/app/createVesting.js"
}
now running
npm run vesting:create
will generate a new UTxO for the smart contract ready to be spent!
We should also see a similar transaction hash in the console now.
8b3deb9095898c4d1385269f0af00febaa547e4b4365978d073519caa52f791e
Spend the locked UTxO
Get all you need
You know the process now:
- read the script
- build the script address
- read address and keys
- query UTxO
These are the steps needed before we can start to build the transaction and are often very similar.
import { Address, DataI, Credential, PrivateKey, CredentialType, Script, DataConstr, DataB, PublicKey, defaultPreprodGenesisInfos, ScriptType } from "@harmoniclabs/plu-ts";
import getTxBuilder from "./getTxBuilder";
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
import blockfrost from "./blockfrost";
import { readFile } from "fs/promises";
async function claimVesting(Blockfrost: BlockfrostPluts)
{
const txBuilder = await getTxBuilder(Blockfrost);
const scriptFile = await readFile("./testnet/vesting.plutus.json", { encoding: "utf-8" });
const script = Script.fromCbor(JSON.parse(scriptFile).cborHex, ScriptType.PlutusV3)
const scriptAddr = new Address(
"testnet",
new Credential(CredentialType.Script, script.hash)
);
const privateKeyFile = await readFile("./testnet/payment2.skey", { encoding: "utf-8" });
const privateKey = PrivateKey.fromCbor( JSON.parse(privateKeyFile).cborHex );
const addr = await readFile("./testnet/address2.addr", { encoding: "utf-8" });
const address = Address.fromString(addr);
const publicKeyFile = await readFile("./testnet/payment2.vkey", { encoding: "utf-8" });
const pkh = PublicKey.fromCbor( JSON.parse(publicKeyFile).cborHex ).hash;
const utxos = await Blockfrost.addressUtxos( address )
.catch( e => { throw new Error ("unable to find utxos at " + addr) });
// atleast has 10 ada
const utxo = utxos.find(utxo => utxo.resolved.value.lovelaces >= 15_000_000);
if (!utxo) {
throw new Error("No utxo with more than 10 ada");
}
/// ...to be continued
}
Query the scriptUtxos
, to specifically use the utxo
that matches publicKeyHash
within its datum.
const scriptUtxos = await Blockfrost.addressUtxos( scriptAddr )
.catch( e => { throw new Error ("unable to find utxos at " + addr) });
// matches with the pkh
const scriptUtxo = scriptUtxos.find(utxo => {
if (utxo.resolved.datum instanceof DataConstr) {
const pkhData = utxo.resolved.datum.fields[0];
if (pkhData instanceof DataB) {
return pkh.toString() == Buffer.from( pkhData.bytes.toBuffer() ).toString("hex")
}
}
return false;
});
if (!scriptUtxo) {
throw new Error ("No script utxo found for the pkh")
}
Note that we are reading the keys (both private and public) of the beneficiary, we had set in the previous script, here.
If we used the other keys the script would fail each time!
Build the Transaciton
This time our transaction will be formed as follows
- one of our utxos
- the UTxO locked at the script address (with corresponding
Script
in order to validate the spending of it) - the public key hash as
requiredSigners
element so that it is aviable inctx.tx.signatories
- our UTxO as collateral input that MUST be present every time a script is included in the transaciton
- the
invalidBefore
field corresponding to the last slot heigth (otherwise the transaciton interval is negative infinite and the contract will fail!)
Of the above, the last one sounds courious... How do we get the last slot of the blockchain?
Blockfrost helps us with that!
we just have to do,
(await Blockfrost.getChainTip()).slot!
Before building the transaction, we have to ensure the txBuilder genesisInfo is set to its Preprod defaults, by:
txBuilder.setGenesisInfos( defaultPreprodGenesisInfos )
Now the Transaction can be built as follows:
let tx = await txBuilder.buildSync({
inputs: [
{ utxo: utxo },
{
utxo: scriptUtxo,
inputScript: {
script: script,
datum: "inline",
redeemer: new DataI( 0 )
}
}
],
requiredSigners: [ pkh ], // required to be included in script context
collaterals: [ utxo ],
changeAddress: address,
invalidBefore: (await Blockfrost.getChainTip()).slot!
});
Finally, after we add the Sign and Submit code as done previously:
await tx.signWith( privateKey );
const submittedTx = await Blockfrost.submitTx( tx )
we can put all together in a claimVesting.ts
file in the app
folder:
import { Address, DataI, Credential, PrivateKey, CredentialType, Script, DataConstr, DataB, PublicKey, defaultPreprodGenesisInfos, ScriptType } from "@harmoniclabs/plu-ts";
import getTxBuilder from "./getTxBuilder";
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
import blockfrost from "./blockfrost";
import { readFile } from "fs/promises";
async function claimVesting(Blockfrost: BlockfrostPluts)
{
const txBuilder = await getTxBuilder(Blockfrost);
const scriptFile = await readFile("./testnet/vesting.plutus.json", { encoding: "utf-8" });
const script = Script.fromCbor(JSON.parse(scriptFile).cborHex, ScriptType.PlutusV3)
const scriptAddr = new Address(
"testnet",
new Credential(CredentialType.Script, script.hash)
);
const privateKeyFile = await readFile("./testnet/payment2.skey", { encoding: "utf-8" });
const privateKey = PrivateKey.fromCbor( JSON.parse(privateKeyFile).cborHex );
const addr = await readFile("./testnet/address2.addr", { encoding: "utf-8" });
const address = Address.fromString(addr);
const publicKeyFile = await readFile("./testnet/payment2.vkey", { encoding: "utf-8" });
const pkh = PublicKey.fromCbor( JSON.parse(publicKeyFile).cborHex ).hash;
const utxos = await Blockfrost.addressUtxos( address )
.catch( e => { throw new Error ("unable to find utxos at " + addr) });
// atleast has 10 ada
const utxo = utxos.find(utxo => utxo.resolved.value.lovelaces >= 15_000_000);
if (!utxo) {
throw new Error("No utxo with more than 10 ada");
}
const scriptUtxos = await Blockfrost.addressUtxos( scriptAddr )
.catch( e => { throw new Error ("unable to find utxos at " + addr) });
// matches with the pkh
const scriptUtxo = scriptUtxos.find(utxo => {
if (utxo.resolved.datum instanceof DataConstr) {
const pkhData = utxo.resolved.datum.fields[0];
if (pkhData instanceof DataB) {
return pkh.toString() == Buffer.from( pkhData.bytes.toBuffer() ).toString("hex")
}
}
return false;
});
if (!scriptUtxo) {
throw new Error ("No script utxo found for the pkh")
}
txBuilder.setGenesisInfos( defaultPreprodGenesisInfos )
if (Buffer.from(script.hash.toBuffer()).toString("hex") !== Buffer.from(scriptAddr.paymentCreds.hash.toBuffer()).toString("hex")) {
throw new Error("Script hash and script address hash do not match");
}
let tx = await txBuilder.buildSync({
inputs: [
{ utxo: utxo },
{
utxo: scriptUtxo,
inputScript: {
script: script,
datum: "inline",
redeemer: new DataI( 0 )
}
}
],
requiredSigners: [ pkh ], // required to be included in script context
collaterals: [ utxo ],
changeAddress: address,
invalidBefore: (await Blockfrost.getChainTip()).slot!
});
await tx.signWith( privateKey )
const submittedTx = await Blockfrost.submitTx( tx );
console.log(submittedTx);
}
if( process.argv[1].includes("claimVesting") )
{
claimVesting(blockfrost());
}
After adding a new npm script
in package.json
"scripts": {
// ...
"vesting:claim": "npm run build:light && node dist/app/claimVesting.js"
}
to try claim the UTxO, we can run:
npm run vesting:claim
If you run the script shortly after you created and locked the UTxO the script will fail!
In the previous script, we had set a locking period of 10 seconds.
So you just have to have a little patience :)
If everything goes correctly, the program should terminate without errors. It will definitely console back the transaction hash in the terminal.
Bonus: Return the tADA
If you were in public testnet remember to return the tADA to the faucet.
For this, you can add the following file and script to automate everything.
import { BlockfrostPluts } from "@harmoniclabs/blockfrost-pluts";
import { Address, ITxBuildInput, IUTxO, PrivateKey } from "@harmoniclabs/plu-ts";
import { readFile } from "fs/promises";
import blockfrost from "./blockfrost";
import getTxBuilder from "./getTxBuilder";
async function returnFaucet(Blockfrost: BlockfrostPluts)
{
const utxos: (ITxBuildInput | IUTxO)[] = [];
const pvtKeys: PrivateKey[] = [];
for( let i = 1; i <= 2; i++ )
{
const pvtKeyFile = await readFile(`./testnet/payment${i}.skey`, { encoding: "utf-8" })
const pvtKey = PrivateKey.fromCbor( JSON.parse(pvtKeyFile).cborHex );
pvtKeys.push( pvtKey );
const addr = await readFile(`./testnet/address${i}.addr`, { encoding: "utf-8" });
const address = Address.fromString(addr);
const addrUtxos = await Blockfrost.addressUtxos( address )
addrUtxos.forEach( utxo => utxos.push({ utxo: utxo }) )
}
const txBuilder = await getTxBuilder(Blockfrost);
let returnTADATx = await txBuilder.buildSync({
inputs: utxos as any,
// the faucet address
changeAddress: "addr_test1qqr585tvlc7ylnqvz8pyqwauzrdu0mxag3m7q56grgmgu7sxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknswgndm3"
});
for(const privateKey of pvtKeys)
{
await returnTADATx.signWith( privateKey );
}
const submittedTx = await Blockfrost.submitTx( returnTADATx );
console.log(submittedTx);
}
if( process.argv[1].includes("returnFaucet") )
{
returnFaucet(blockfrost());
}
"scripts": {
// ...
"vesting:returnFaucet": "npm run build:light && node dist/app/returnFaucet.js"
}
npm run vesting:returnFaucet