Writing
Asynchronous
Cross-Chain
Smart Contracts
Nebular - July 2024
1
@Jovonni
Developer Relations Engineer
Agenda
Nebular Workshop - July 2024
The Destination of Today’s Journey
const unbondAndLiquidStakeFn = async (orch, {
zcf,
celestiaAccount
}, _seat, _offerArgs) => {
const delegations = await celestiaAccount.getDelegations();
await celestiaAccount.undelegate(delegations);
const stride = await orch.getChain('stride');
const strideAccount = await stride.makeAccount();
const tiaAmt = await celestiaAccount.getBalance('TIA');
await celestiaAccount.transfer(tiaAmt*0.90, strideAccount.getAddress());
await strideAccount.liquidStake(tiaAmt);
};
Foundations
Section 1
Writing Asynchronous Cross-Chain Smart Contracts
4
What is Agoric?
What is a Smart Contract
We define a “smart contract” as a contract-like arrangement expressed in code where the behavior of the program enforces the terms of the contract.
What are Object Capabilities?
an object reference familiar from object programming is a permission.
If object Bob has no reference to object Carol, then Bob cannot invoke Carol; Bob can't provoke whatever behavior Carol would have.
If Alice has a reference to Bob and invokes Bob, passing Carol as an argument, then Alice has both used her permission to invoke Bob and given Bob permission to invoke Carol.
Why Hardened JavaScript?
�
Array.prototype.push =
function(element) {
doSomethingFishy()
this[this.length] = element;
};
Hardening Objects
Without Hardened JavaScript, anyone can clobber the properties of our objects.
const makePoint = (x, y) => {
return {
getX: () => x,
getY: () => y,
};
};
const p11 = makePoint(1, 1);
p11.getX = () => 'I am not a number!';
const makePoint = (x, y) => {
return harden({
getX: () => x,
getY: () => y,
});
};
const p11 = makePoint(1, 1);
p11.getX = () => 1; // throws
To prevent tampering, use the harden() function, which is a deep form of Object.freeze.
State Management in Contracts
Contracts can use ordinary variables and data structures for state.
let balance = 1000;
let owner = 'Alice';
let transactions = [];
const rooms = new Map();
Hello World Smart Contract
import { Far } from '@endo/far';
const greet = who => `Hello, ${who}!`;
export const start = () => {
return {
publicFacet: Far('Hello', { greet }),
};
};
Start function: A contract is defined by a JavaScript module that exports a start function
This is similar to the constructor in Solidity, or the instantiate function in Cosmwasm/Rust
Access Control with Objects
Access control is based on separation of powers, and is based on the Object-Capability Model
import { Far } from '@endo/far';
export const start = () => {
let value = 'Hello, World!';
const get = () => value;
const set = v => (value = v);
// Far creates a remotable object
return {
publicFacet: Far('ValueView', { get }),
creatorFacet: Far('ValueCell', { get, set }),
};
};
This pattern allows for:
vats
Basic Concepts
Section 2
Writing Asynchronous Cross-Chain Smart Contracts
14
Basic Concepts
Brand
Amount
Purse
Payment
Asset Kind
Facet
Mint
Issuer
Basic Concepts: Brand
A Brand represents the unique identity of a type of digital asset. It is associated with a specific Mint and Issuer.
Identifies the type of issuer, such as "ATOM", "BLD", “USDC” etc. Brands are one of the two elements that make up an Amount
const brand = await E(tokenIssuer).getBrand();
Basic Concepts: Amount
An Amount is a description of digital assets, answering the questions "how much?" (its Amount Value) and "of what kind?” (its Brand).
Represent specific quantities of digital assets. Amounts can describe either fungible, non-fungible, or semi-fungible assets.
import { AmountMath } from '@agoric/ertp';
const amount = AmountMath.make(someBrand, 1000n);
Agoric Concepts: Issuer
Responsible for validating and managing a specific currency.
An issuer is a trusted authority that validates payments.
const { brand, issuer, mint } = makeIssuerKit('ATOM');
Agoric Concepts: Mint
Mints create new instances of digital assets, such as a new currency, tickets or NTFs.
Only a Mint can issue new digital assets.
A Mint has a one-to-one relationship with both an Issuer and a Brand. It can only mint new assets of that Brand and is the only Mint that can mint new assets of that Brand.
import { makeIssuerKit, AmountMath } from '@agoric/ertp';
const { brand, issuer, mint } = makeIssuerKit('MyToken');
const amount = AmountMath.make(brand, 1000n);
const payment = mint.mintPayment(amount);
Agoric Concepts: Payment
Payments are spendable representations of digital assets.
A unit of digital assets created by a Mint that can be transferred between Purses or used in smart contract transactions
const { mint: atomMint, brand: atomBrand } = makeIssuerKit('ATOM');
const atom123 = AmountMath.make(atomBrand, 123n);
const atomPayment = atomMint.mintPayment(atom123);
Agoric Concepts: Purse
Purses are containers for holding digital assets of a specific brand.
An object that holds a balance of digital assets created by a specific Mint
Purses can receive deposits and allow withdrawals
const { issuer: atomIssuer, mint: atomMint, brand: atomBrand } = makeIssuerKit('ATOM');
const atomPurse = atomIssuer.makeEmptyPurse();
const atom123 = AmountMath.make(atomBrand, 123n);
const atomPayment = atomMint.mintPayment(atom123);
atomPurse.deposit(atomPayment)
...
atomPurse.withdraw(atomPayment)
Agoric Concepts: Asset Kind (Fungible/Non-Fungible)
There are several kinds of Assets.��AssetKind.NAT: Used with fungible assets.��AssetKind.COPY_BAG: Used with semi-fungible assets where there can be duplicates.�
import { AssetKind, makeIssuerKit } from '@agoric/ertp';
�// Defaults to AssetKind.NAT
makeIssuerKit('ATOM');
makeIssuerKit('concertTickets', AssetKind.COPY_BAG);
Agoric Concepts: Facet
A facet is an object that exposes an API or particular view of some larger entity, which may be an object itself
import { Far } from '@endo/far';
export const start = () => {
let value = 'Hello, World!';
const get = () => value;
const set = v => (value = v);
// Far creates a remotable object
return {
publicFacet: Far('ValueView', { get }), // read-only
creatorFacet: Far('ValueCell', { get, set }), // read/write
};
};
Writing Contracts
Section 3
Writing Asynchronous Cross-Chain Smart Contracts
24
Offer Safety
Offer Safety means that the user is guaranteed to either get what they said they wanted or receive a full refund of what they offered.
Zoe Concepts
The Zoe framework provides a way to write smart contracts without having to worry about offer safety.�
Invitation
Offer
Seat
Zoe
Proposal
Zoe Concepts: Zoe
Zoe is a service and smart contract API designed for trading assets with reduced risk.
For Users:
For Developers:
Zoe Concepts: Zoe Contract Facet(ZCF)
A Zoe Contract Facet (ZCF) is an API object for a running contract instance to access the Zoe state for that instance.
A ZCF is accessed synchronously from within the contract, and usually is referred to in code as zcf.
The contract instance is launched by E(zoe).startInstance(...) and is given access to the zcf object during that launch
const start = async zcf => {
// Contract Logic
};
Zoe Concepts: Proposal
Proposals are records with give, want, and/or exit properties
const proposal = harden({
give: { Price: AmountMath.make(brandA, 5n); },
want: { Items: AmountMath.make(brandB, 4n); },
exit: { onDemand: null },
});
Zoe Concepts: Invitation
A payment whose amount represents participation in a contract instance.
Zoe Concepts: Invitation
const publicFacet = E(zoe).getPublicFacet(instance);
const terms = await E(zoe).getTerms(instance);
const { issuers, brands, tradePrice } = terms;
const choices = makeCopyBag([['map', 1n], ['scroll', 1n]]);
� const proposal = {
give: { Price: tradePrice },
want: { Items: AmountMath.make(brands.Item, choices) },
};
const pmt = await E(purse).withdraw(tradePrice);
const toTrade = E(publicFacet).makeTradeInvitation();
const seat = E(zoe).offer(toTrade, proposal, { Price: pmt });
const items = await E(seat).getPayout('Items');
Create Invitation to trade, and submit offer with proposal using the invitation
Zoe Concepts: Offer
Users interact with contract instances by making offers.��In Zoe, an offer consists of:
Zoe Concepts: Offer
const publicFacet = E(zoe).getPublicFacet(instance);
const terms = await E(zoe).getTerms(instance);
const { issuers, brands, tradePrice } = terms;
const choices = makeCopyBag([['map', 1n], ['scroll', 1n]]);
const proposal = {
give: { Price: tradePrice },
want: { Items: AmountMath.make(brands.Item, choices) },
};
const pmt = await E(purse).withdraw(tradePrice);
const toTrade = E(publicFacet).makeTradeInvitation();
const seat = E(zoe).offer(toTrade, proposal, { Price: pmt });
const items = await E(seat).getPayout('Items');
Create Proposal
Make Invitation, and submit offer using the proposal, and invitation
Zoe Concepts: Seats
Zoe uses a seat to represent an offer in progress, and has two seat facets representing two views of the same seat:
Zoe Concepts: ZCF Seat
const tradeHandler = buyerSeat => {
const { want } = buyerSeat.getProposal();
...
sum(bagCounts(want.Items.value)) <= maxItems ||
Fail`max ${q(maxItems)} items allowed: ${q(want.Items)}`;
const newItems = itemMint.mintGains(want); // returns a zcfSeat
atomicRearrange(
zcf,
harden([
[buyerSeat, proceeds, { Price: tradePrice }],
[newItems, buyerSeat, want],
]),
);
buyerSeat.exit(true); // exits a zcfSeat
newItems.exit(); // exits a zcfSeat
return 'trade complete';
};
A ZCF seat (buyerSeat) is passed as an argument to the offer handler
Zoe Concepts: User Seat
const publicFacet = E(zoe).getPublicFacet(instance);
const terms = await E(zoe).getTerms(instance);
const { issuers, brands, tradePrice } = terms;
const choices = makeCopyBag([['map', 1n], ['scroll', 1n]]);
� const proposal = {
give: { Price: tradePrice },
want: { Items: AmountMath.make(brands.Item, choices) },
};�
const pmt = await E(purse).withdraw(tradePrice);
const toTrade = E(publicFacet).makeTradeInvitation();
const seat = E(zoe).offer(toTrade, proposal, { Price: pmt });
const items = await E(seat).getPayout('Items');
User receives a seat upon submitting an offer, and uses it to get payouts from the seat
Contract Concepts: Offer Handlers
const tradeHandler = buyerSeat => {
const { want } = buyerSeat.getProposal();
sum(bagCounts(want.Items.value)) <= maxItems ||
Fail`max ${q(maxItems)} items allowed`;
const newItems = itemMint.mintGains(want); // returns a zcfSeat
atomicRearrange(...);
buyerSeat.exit(true);
newItems.exit();
return 'trade complete';
};
Contract Concepts: Private args
Contract Concepts: Public Facet
const publicFacet = Far('Items Public Facet', {
makeTradeInvitation() {
return zcf.makeInvitation(tradeHandler, 'buy items', undefined, proposalShape);
},
});
return harden({ publicFacet });
Contract Concepts: Creator Facet
const limitedCreatorFacet = Far('Creator', {
makePriviledgedInvitation() {
return makePriviledgedInvitation(zcf, feeSeat, feeBrand, 'Fee');
},
});
Terms
const { issuers, brands } = await E(zoe).getTerms(instance);
How do two VATs communicate?
Eventual Send - E(...)
Example: Install Contract
const installationHandle = zoe.install(bundle)
import { E } from '@endo/eventual-send';
E(zoe).install(bundle)
.then(installationHandle => { ... })
.catch(err => { ... });
Presence
VStorage
VStorage: Reading and Writing
Write-Only For Contracts
From within the JavaScript VM, nodes in the chainStorage API are write-only.
This means that contracts can write or publish data to these nodes, but they cannot read from them within the VM.
Read-Only For Clients
Clients can only read data from VStorage. They cannot modify it.
The client is generally an off-chain UI, from where users submit transactions, and the UI then reads results the contract writes to vstorage
VStorage: A Visual
vstorage
contract
RPC query
E(storageNode(key)).setValue(value);
value
VStorage: Write Data Example
Using E(privateArgs.storageNode).makeChildNode('metrics') gives the contract access to write to the published.myContract.metrics key and all keys under it.
export const start = (zcf, privateArgs) => {
const storageNode = E(privateArgs.storageNode).makeChildNode(
'metrics',
);
const setValue = async (key, value) => {
await E(storageNode(key)).setValue(value);
};
}
VStorage: Read Data
agd query vstorage data 'published.agoricNames'
agd query vstorage children 'published.agoricNames'
children:
- brand
- installation
- instance
Upgrading
Contracts
Section 4
Writing Asynchronous Cross-Chain Smart Contracts
51
What is upgrading a contract?
State-based Upgrade
Replay-based Upgrade
Why upgrade a contract?
Stores
Specialized data structures used to manage and persist key-value pairs or sets of unique keys
Durability: baggage
import { makeDurableZone } from '@agoric/zone/durable.js';
const zone = makeDurableZone(baggage);
const rooms = zone.mapStore('rooms');
Secure Upgrade with baggage
This approach to contract upgrade preserves access to objects to ensure that the contract doesn’t violate Object Capability security properties
Exo Object (Exposed Remotable Object)
An Exo object is a special kind of object in the Endo framework Exo objects are usually defined with an InterfaceGuard.
Making Exos in Zones
const zone = makeDurableZone(baggage);
const publicFacet = zone.exo(
'StakeAtom',
M.interface('StakeAtomI', {
makeAccount: M.call().returns(M.remotable('ChainAccount')),
}),
{
async makeAccount() {
return await makeAccountKit();
},
},
);
Exo Object (Exposed Remotable Object)
Exos - How to Create an Exo
Here we use zone.exo to create an exo
const publicFacet = zone.exo(
'Send PF',
M.interface('Send PF', {
makeSendInvitation: M.call().returns(InvitationShape),
}),
{
makeSendInvitation() {
return zcf.makeInvitation(
sendIt,
'send',
undefined,
M.splitRecord({ give: SingleAmountRecord }),
);
},
},
);
Interface
Guard
Method(s)
Orchestration
Contracts
Section 5
Writing Asynchronous Cross-Chain Smart Contracts
61
This UX is cumbersome with multichain protocols alone
Use Case: I want to liquid-stake my bonded TIA
to receive an airdrop from Stride
Approve undelegate�Keplr Wallet
Liquid stake TIA
Stride application
Approve transfer�Keplr Wallet
Approve LST�Keplr Wallet
… wait 21 days …
Transfer TIA�Stride application
Undelegate TIA�Keplr Dashboard
Orchestration Concepts
Interchain Accounts (ICA)
Chain Interface
Provides a unified interface for the orchestration logic to manage cross-chain operations effectively.
Remote Implementation
Local Implementation
OrchestrationAccount Interface
Has an API to
const address = await orchestrationAccount.getAddress();
const balances = await orchestrationAccount.getBalances();
const balance = await orchestrationAccount.getBalance('uatom');
await orchestrationAccount.send(receiverAddress, amount);
await orchestrationAccount.transfer(amount, destinationAddress);
await orchestrationAccount.transferSteps(amount, transferMsg);
Orchestrator Interface
Orchestrator Interface exposes the following important API
This UX is cumbersome with multichain protocols alone
Use Case: I want to liquid-stake my bonded TIA
to receive an airdrop from Stride
Approve undelegate�Keplr Wallet
Liquid stake TIA
Stride application
Approve transfer�Keplr Wallet
Approve LST�Keplr Wallet
… wait 21 days …
Transfer TIA�Stride application
Undelegate TIA�Keplr Dashboard
Orchestration Contracts - Cross Chain Unbond
// ICA was previously created, funded, and made a delegation
const unbondAndLiquidStakeFn = async (orch, {
zcf,
celestiaAccount
}, _seat, _offerArgs) => {
const delegations = await celestiaAccount.getDelegations();
// wait for undelegate to be complete (celestia unbonding period is 21 days)
await celestiaAccount.undelegate(delegations);
const stride = await orch.getChain('stride');
const strideAccount = await stride.makeAccount();
const tiaAmt = await celestiaAccount.getBalance('TIA');
await celestiaAccount.transfer(multiply(tiaAmt,0.90), strideAccount.getAddress());
await strideAccount.liquidStake(multiply(tiaAmt,0.90));
};
Get TIA Delegations Amount
Undelegate TIA
Make ICA
Transfer
Liquid Stake transferred TIA
Orchestration Contracts - Cross Chain Unbond
unbondAndLiquidStakeFn Is shown on the next slide
const contract = async (zcf, privateArgs, zone, { orchestrate }) => {
const unbondAndLiquidStake = orchestrate(
'LSTTia',
{ zcf },
unbondAndLiquidStakeFn,
);
const publicFacet = zone.exo('publicFacet', undefined, {
makeUnbondAndLiquidStakeInvitation() {
return zcf.makeInvitation(
unbondAndLiquidStake,
'Unbond and liquid stake',
undefined,
harden({
give: {},
want: {},
exit: M.any(),
}),
);
},
});
return harden({ publicFacet });
};
export const start = withOrchestration(contract);
Orchestration Contracts - Cross Chain Swap
const stakeAndSwapFn = async (orch, { zcf }, seat, offerArgs) => {
const { give } = seat.getProposal();
const omni = await orch.getChain('omniflixhub');
const agoric = await orch.getChain('agoric');
const [omniAccount, localAccount] = await Promise.all([
omni.makeAccount(),
agoric.makeAccount(),
]);
const omniAddress = omniAccount.getAddress();
// deposit funds from user seat to LocalChainAccount
const payments = await withdrawFromSeat(zcf, seat, give);
await deeplyFulfilled(
objectMap(payments, payment =>
localAccount.deposit(payment),
),
);
seat.exit();
...
// build swap instructions with orcUtils library
const transferMsg = orcUtils.makeOsmosisSwap({
destChain: 'omniflixhub',
destAddress: omniAddress,
amountIn: give.Stable,
brandOut: '...',
slippage: 0.03,
});
await localAccount
.transferSteps(give.Stable, transferMsg)
.then(_txResult =>
omniAccount.delegate(
offerArgs.validator,
offerArgs.staked),
)
.catch(e => console.error(e));
}; // end of stakeAndSwapFn
Make
Accounts
Deposit into Account
Execute Transfer then delegate
Orchestration Contracts - Cross Chain Swap
stakeAndSwapFn Is shown on the next slide
const contract = async (zcf, privateArgs, zone, { orchestrate }) => {
const { brands } = zcf.getTerms();
const swapAndStakeHandler = orchestrate('LSTTia', { zcf }, stakeAndSwapFn);
const publicFacet = zone.exo('publicFacet', undefined, {
makeSwapAndStakeInvitation() {
return zcf.makeInvitation(
swapAndStakeHandler,
'Swap for TIA and stake',
undefined,
harden({
give: { Stable: makeNatAmountShape(brands.Stable, 1n) },
want: {},
exit: M.any(),
}),
);
},
});
return harden({ publicFacet });
};
export const start = withOrchestration(contract);
Recap of Today’s Journey
74
75