# Smart Wallet Dapp Architecture
The Agoric Platform consists of smart contracts and services such as Zoe running in a Hardened JavaScript VM running on top of a Cosmos SDK consensus layer. Clients interact with the consensus layer by making queries and submitting messages in signed transactions. In the Smart Wallet Architecture, dapps consist of
- Hardened JavaScript smart contracts
- clients that can submit offers and query status via the consensus layer
- A client formats an offer, signs it, and broadcasts it.
- The offer is routed to the
walletFactory
contract, which finds (or creates) thesmartWallet
object associated with the signer's address and uses it to execute the offer. - The
smartWallet
callsE(zoe).offer(...)
and monitors the status of the offer, emitting it for clients to query. - Zoe escrows the payments and forwards the proposal to the contract indicated by the offer.
- The contract tells Zoe how to reallocate assets.
- Zoe ensures that the reallocations respect offer safety and then provides payouts accordingly.
- The client's query tells it that the payouts are available.
# Signing and Broadcasting Offers
One way to sign and broadcast offers is with the agd tx ...
command. For example:
agd tx swingset wallet-action --allow-spend "$ACTION" \
--chain-id=agoriclocal --from=acct1
Another is using a wallet signing UI such as Keplr via the Keplr API (opens new window).
Given sufficient care with key management, a cosmjs SigningStargateClient (opens new window) or any other client that can deliver a agoric.swingset.MsgWalletSpendAction (opens new window) to a Cosmos SDK endpoint (opens new window) works.
message MsgWalletSpendAction {
bytes owner = 1;
string spend_action = 2;
}
# Querying VStorage
VStorage (opens new window) (for "Virtual Storage") is a key-value store that is
read-only for clients of the consensus layer.
From within the JavaScript VM, it is accessed via a chainStorage
API with a node at each
key that is write-only; a bit like a console
.
The protobuf definition is agoric.vstorage.Query (opens new window):
service Query {
// Return an arbitrary vstorage datum.
rpc Data(QueryDataRequest) returns (QueryDataResponse) {
option (google.api.http).get = "/agoric/vstorage/data/{path}";
}
// Return the children of a given vstorage path.
rpc Children(QueryChildrenRequest)
returns (QueryChildrenResponse) {
option (google.api.http).get = "/agoric/vstorage/children/{path}";
}
}
We can issue queries using, agd query ...
:
$ agd query vstorage children 'published.agoricNames'
children:
- brand
- installation
- instance
...
The Agoric CLI follow
command supports vstorage
query plus some of the marshalling conventions discussed below:
$ agoric follow -lF :published.agoricNames.brand
[
[
"BLD",
slotToVal("board0566","Alleged: BLD brand"),
],
[
"IST",
slotToVal("board0257","Alleged: IST brand"),
],
...
]
vstorage viewer by p2p
The vstorage-viewer (opens new window) contributed by p2p is often very handy:
# Specifying Offers
Recall that for an agent within the JavaScript VM,
E(zoe).offer(...)
takes an Invitation
and optionally a Proposal
with { give, want, exit }
, a PaymentPKeywordRecord
, and offerArgs
; it returns a UserSeat
from which we can getPayouts().
In the Smart Wallet architecture, a client uses an OfferSpec
to
tell its SmartWallet
how to conduct an offer.
It includes an invitationSpec
to say what invitation to pass to Zoe. For example:
/** @type {import('@agoric/smart-wallet').InvitationSpec} */
const invitationSpec = {
source: 'contract',
instance,
publicInvitationMaker: 'makeBattleInvitation',
invitationArgs: ['troll'],
};
Here the SmartWallet
calls E(zoe).getPublicFacet(instance)
and then
uses the publicInvitationMaker
and invitationArgs
to call the contract's
public facet.
InvitationSpec Patterns
For more InvitationSpec
examples, see How to make an offer from a dapp via the smart wallet? (InvitationSpec Patterns) · #8082 (opens new window) July 2023
The client fills in the proposal, which instructs the SmartWallet
to withdraw corresponding payments to send to Zoe.
/** @type {import('@agoric/smart-wallet').BridgeAction} */
const action = harden({
method: 'executeOffer',
offer: {
id: 'battle7651',
invitationSpec,
proposal: {
give: { Gold: AmountMath.make(brands.gold, 100n) },
},
},
});
But recall the spend_action
field in MsgWalletSpendAction
is a string.
In fact, the expected string in this case is of the form:
t.regex(spendAction, /^{"body":"#.*","slots":\["board123","board32342"\]}$/);
const goldStuff =
'\\"brand\\":\\"$1.Alleged: Gold Brand\\",\\"value\\":\\"+100\\"';
t.true(spendAction.includes(goldStuff));
We recognize "method":"executeOffer"
and such, but
body:
, slots:
, and $1.Alleged: Gold Brand
need further explanation.
# Marshalling Amounts and Instances
Watch: Office Hours Discussion of Marshal
To start with, amounts include bigint
s. The @endo/marshal
API handles those:
const m = makeMarshal(undefined, undefined, smallCaps);
const stuff = harden([1, 2, 3n, undefined, NaN]);
const capData = m.toCapData(stuff);
t.deepEqual(m.fromCapData(capData), stuff);
To marshal brands and instances, recall from the discussion of marshal in eventual send how remotables are marshalled with a translation table.
The Agoric Board is a well-known name service that issues plain string identifiers for object identities and other passable keys (that is: passable values excluding promises and errors). Contracts and other services can use its table of identifiers as a marshal translation table:
/** @type {Record<string, Brand>} */
const brands = {
gold: asset.gold.brand,
victory: asset.victory.brand,
};
// explicitly register brand using the board API
const victoryBrandBoardId = await E(theBoard).getId(brands.victory);
t.is(victoryBrandBoardId, 'board0371');
// When the publishing marshaler needs a reference marker for something
// such as the gold brand, it issues a new board id.
const pubm = E(theBoard).getPublishingMarshaller();
const brandData = await E(pubm).toCapData(brands);
t.deepEqual(brandData, {
body: `#${JSON.stringify({
gold: '$0.Alleged: Gold Brand',
victory: '$1.Alleged: Victory Brand',
})}`,
slots: ['board0592', 'board0371'],
});
To reverse the process, clients can mirror the on-chain board translation table by synthesizing a remotable for each reference marker received:
const makeBoardContext = () => {
const synthesizeRemotable = (_slot, iface) =>
Far(iface.replace(/^Alleged: /, ''), {});
const { convertValToSlot, convertSlotToVal } = makeTranslationTable(
slot => Fail`unknown id: ${slot}`,
synthesizeRemotable,
);
const marshaller = makeMarshal(convertValToSlot, convertSlotToVal, smallCaps);
/** Read-only board work-alike. */
const board = harden({
getId: convertValToSlot,
getValue: convertSlotToVal,
});
return harden({
board,
marshaller,
/**
* Unmarshall capData, synthesizing a Remotable for each boardID slot.
*
* @type {(cd: import("@endo/marshal").CapData<string>) => unknown }
*/
ingest: marshaller.fromCapData,
});
};
Now we can take results of vstorage queries for Data('published.agoricNames.brand')
and Data('published.agoricNames.instance')
unmarshal ("ingest") them:
const clientContext = makeBoardContext();
const brandQueryResult = {
body: `#${JSON.stringify({
gold: '$1.Alleged: Gold Brand',
victory: '$0.Alleged: Victory Brand',
})}`,
slots: ['board0371', 'board32342'],
};
const brands = clientContext.ingest(brandQueryResult);
const { game1: instance } = clientContext.ingest(instanceQueryResult);
And now we have all the pieces of the BridgeAction
above.
The marshalled form is:
t.deepEqual(clientContext.marshaller.toCapData(action), {
body: `#${JSON.stringify({
method: 'executeOffer',
offer: {
id: 'battle7651',
invitationSpec: {
instance: '$0.Alleged: Instance',
invitationArgs: ['troll'],
publicInvitationMaker: 'makeBattleInvitation',
source: 'contract',
},
proposal: {
give: {
Gold: { brand: '$1.Alleged: Gold Brand', value: '+100' },
},
},
},
})}`,
slots: ['board123', 'board32342'],
});
We still don't quite have a single string for the spend_action
field.
We need to stringify
the CapData
:
const spendAction = JSON.stringify(
clientContext.marshaller.toCapData(action),
);
And now we have the spend_action
in the expected form:
t.regex(spendAction, /^{"body":"#.*","slots":\["board123","board32342"\]}$/);
const goldStuff =
'\\"brand\\":\\"$1.Alleged: Gold Brand\\",\\"value\\":\\"+100\\"';
t.true(spendAction.includes(goldStuff));
The wallet factory can now JSON.parse
this string
into CapData
and unmarshal it using a board marshaller
to convert board ids back into brands, instances, etc.
# Smart Wallet VStorage Topics
Each smart wallet has a node under published.wallet
:
$ agd query vstorage children published.wallet
children:
- agoric1h4d3mdvyqhy2vnw2shq4pm5duz5u8wa33jy6cl
- agoric1qx2kqqdk80fdasldzkqu86tg4rhtaufs00na3y
- agoric1rhul0rxa2z829a6xkrvuq8m8wjwekyduv7dzfj
...
Smart wallet clients should start by getting the current state
at published.${ADDRESS}.current
and then subscribe to updates
at published.${ADDRESS}
. For example, we can use agoric follow -lF
to get the latest .current
record:
$ agoric follow -lF :published.wallet.agoric1h4d3mdvyqhy2vnw2shq4pm5duz5u8wa33jy6cl.current
{
liveOffers: [],
offerToPublicSubscriberPaths: [
[
"openVault-1691526589332",
{
vault: "published.vaultFactory.managers.manager0.vaults.vault2",
},
],
],
offerToUsedInvitation: [
[
"openVault-1691526589332",
{
brand: slotToVal("board0074","Alleged: Zoe Invitation brand"),
value: [
{
description: "manager0: MakeVault",
handle: slotToVal(null,"Alleged: InvitationHandle"),
installation: slotToVal("board05815","Alleged: BundleIDInstallation"),
instance: slotToVal("board00360","Alleged: InstanceHandle"),
},
],
},
],
],
purses: [
{
balance: {
brand: slotToVal("board0074"),
value: [],
},
brand: slotToVal("board0074"),
},
],
}
Then we can use agoric follow
without any options to
get a stream of updates as they appear.
agoric follow :published.wallet.agoric1h4d3mdvyqhy2vnw2shq4pm5duz5u8wa33jy6cl
...
{
status: {
id: "closeVault-1691526597848",
invitationSpec: {
invitationMakerName: "CloseVault",
previousOffer: "openVault-1691526589332",
source: "continuing",
},
numWantsSatisfied: 1,
payouts: {
Collateral: {
brand: slotToVal("board05557","Alleged: ATOM brand"),
value: 13000000n,
},
Minted: {
brand: slotToVal("board0257","Alleged: IST brand"),
value: 215000n,
},
},
proposal: {
give: {
Minted: {
brand: slotToVal("board0257"),
value: 5750000n,
},
},
want: {},
},
result: "your vault is closed, thank you for your business",
},
updated: "offerStatus",
}
Note that status updates are emitted at several points in the handling of each offer:
- when the
getOfferResult()
promise settles - when the
numWantsSatisfied()
promise settles - when the payouts have been deposited.
And we may get balance
updates at any time.
The data published via vstorage are available within the JavaScript VM via the getPublicTopics (opens new window) API.
The CurrentWalletRecord (opens new window) type is:
{
purses: Array<{brand: Brand, balance: Amount}>,
offerToUsedInvitation: Array<[ offerId: string, usedInvitation: Amount ]>,
offerToPublicSubscriberPaths: Array<[ offerId: string, publicTopics: { [subscriberName: string]: string } ]>,
liveOffers: Array<[import('./offers.js').OfferId, import('./offers.js').OfferStatus]>,
}
And UpdateRecord (opens new window) is:
{ updated: 'offerStatus', status: import('./offers.js').OfferStatus }
| { updated: 'balance'; currentAmount: Amount }
| { updated: 'walletAction'; status: { error: string } }
Both of those types include OfferStatus (opens new window) by reference:
import('./offers.js').OfferSpec & {
error?: string,
numWantsSatisfied?: number
result?: unknown | typeof UNPUBLISHED_RESULT,
payouts?: AmountKeywordRecord,
}
# VBank Assets and Cosmos Bank Balances
Note that balances of assets such as IST and BLD are already available via consensus layer queries to the Cosmos SDK bank module (opens new window).
$ agd query bank balances agoric1h4d3mdvyqhy2vnw2shq4pm5duz5u8wa33jy6cl -o json | jq .balances
[
{
"denom": "ibc/BA313C4A19DFBF943586C0387E6B11286F9E416B4DD27574E6909CABE0E342FA",
"amount": "100000000"
},
{
"denom": "ubld",
"amount": "10000000"
},
{
"denom": "uist",
"amount": "215000"
}
]
They are not published redundantly in vstorage and nor does the
smart wallet emit balance
updates for them.
To get the correspondence between certain cosmos denoms (chosen by governance)
and their ERTP brands, issuers, and display info such as decimalPlaces
,
see published.agoricNames.vbankAsset
:
agoric follow -lF :published.agoricNames.vbankAsset
[
[
"ibc/BA313C4A19DFBF943586C0387E6B11286F9E416B4DD27574E6909CABE0E342FA",
{
brand: slotToVal("board05557","Alleged: ATOM brand"),
denom: "ibc/BA313C4A19DFBF943586C0387E6B11286F9E416B4DD27574E6909CABE0E342FA",
displayInfo: {
assetKind: "nat",
decimalPlaces: 6,
},
issuer: slotToVal("board02656","Alleged: ATOM issuer"),
issuerName: "ATOM",
proposedName: "ATOM",
},
],
[
"ubld",
{
brand: slotToVal("board0566","Alleged: BLD brand"),
denom: "ubld",
displayInfo: {
assetKind: "nat",
decimalPlaces: 6,
},
issuer: slotToVal("board0592","Alleged: BLD issuer"),
issuerName: "BLD",
proposedName: "Agoric staking token",
},
],
[
"uist",
{
brand: slotToVal("board0257","Alleged: IST brand"),
denom: "uist",
displayInfo: {
assetKind: "nat",
decimalPlaces: 6,
},
issuer: slotToVal("board0223","Alleged: IST issuer"),
issuerName: "IST",
proposedName: "Agoric stable token",
},
],
...
]