From b30ce8294b0bc5d5bcd34fc3a68db540f9b20028 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Sat, 9 Nov 2024 12:08:31 -0800 Subject: [PATCH] feat(vats): ERTP face on orch assets --- packages/vats/package.json | 2 + packages/vats/src/orch-purse/issuerKit.js | 293 +++++++++++++ .../vats/src/orch-purse/mock-orch-chain.js | 203 +++++++++ packages/vats/src/orch-purse/notes.txt | 38 ++ packages/vats/src/orch-purse/paymentLedger.js | 401 ++++++++++++++++++ packages/vats/src/orch-purse/purse.js | 190 +++++++++ packages/vats/src/orch-purse/typeGuards.js | 126 ++++++ packages/vats/src/orch-purse/types.ts | 160 +++++++ .../test/orch-purse/mock-orch-chain.test.js | 14 + 9 files changed, 1427 insertions(+) create mode 100644 packages/vats/src/orch-purse/issuerKit.js create mode 100644 packages/vats/src/orch-purse/mock-orch-chain.js create mode 100644 packages/vats/src/orch-purse/notes.txt create mode 100644 packages/vats/src/orch-purse/paymentLedger.js create mode 100644 packages/vats/src/orch-purse/purse.js create mode 100644 packages/vats/src/orch-purse/typeGuards.js create mode 100644 packages/vats/src/orch-purse/types.ts create mode 100644 packages/vats/test/orch-purse/mock-orch-chain.test.js diff --git a/packages/vats/package.json b/packages/vats/package.json index ebb304b455bd..872d66377579 100644 --- a/packages/vats/package.json +++ b/packages/vats/package.json @@ -23,12 +23,14 @@ "license": "Apache-2.0", "dependencies": { "@endo/errors": "^1.2.7", + "@endo/exo": "^1.5.6", "@agoric/cosmic-proto": "^0.4.0", "@agoric/ertp": "^0.16.2", "@agoric/governance": "^0.10.3", "@agoric/internal": "^0.3.2", "@agoric/network": "^0.1.0", "@agoric/notifier": "^0.6.2", + "@agoric/orchestration": "^0.1.0", "@agoric/store": "^0.9.2", "@agoric/swingset-vat": "^0.32.2", "@agoric/time": "^0.3.2", diff --git a/packages/vats/src/orch-purse/issuerKit.js b/packages/vats/src/orch-purse/issuerKit.js new file mode 100644 index 000000000000..64a12dd44218 --- /dev/null +++ b/packages/vats/src/orch-purse/issuerKit.js @@ -0,0 +1,293 @@ +// @jessie-check + +import { assert, Fail } from '@endo/errors'; +import { assertPattern } from '@endo/patterns'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { AssetKind, assertAssetKind } from '@agoric/ertp'; +import { coerceDisplayInfo } from '@agoric/ertp/src/displayInfo.js'; + +import { preparePaymentLedger } from './paymentLedger.js'; + +/** + * @import {ShutdownWithFailure} from '@agoric/swingset-vat' + * @import {AdditionalDisplayInfo, RecoverySetsOption, IssuerKit, PaymentLedger} from '@agoric/ertp' + */ + +/** + * @template {AssetKind} K + * @typedef {object} IssuerRecord + * @property {string} name + * @property {K} assetKind + * @property {AdditionalDisplayInfo} displayInfo + * @property {Pattern} elementShape + */ + +/** + * Used _only_ internally, to make a new issuerKit or to revive an old one. + * + * @template {AssetKind} K + * @param {IssuerRecord} issuerRecord + * @param {import('@agoric/zone').Zone} issuerZone + * @param {RecoverySetsOption} recoverySetsState Omitted from issuerRecord + * because it was added in an upgrade. + * @param {ShutdownWithFailure} [optShutdownWithFailure] If this issuer fails in + * the middle of an atomic action (which btw should never happen), it + * potentially leaves its ledger in a corrupted state. If this function was + * provided, then the failed atomic action will call it, so that some larger + * unit of computation, like the enclosing vat, can be shutdown before + * anything else is corrupted by that corrupted state. See + * https://github.com/Agoric/agoric-sdk/issues/3434 + * @returns {IssuerKit} + */ +const setupIssuerKit = ( + { name, assetKind, displayInfo, elementShape }, + issuerZone, + recoverySetsState, + optShutdownWithFailure = undefined, +) => { + assert.typeof(name, 'string'); + assertAssetKind(assetKind); + + // Add assetKind to displayInfo, or override if present + const cleanDisplayInfo = coerceDisplayInfo(displayInfo, assetKind); + if (optShutdownWithFailure !== undefined) { + assert.typeof(optShutdownWithFailure, 'function'); + } + + if (elementShape !== undefined) { + assertPattern(elementShape); + } + + // Attenuate the powerful authority to mint and change balances + /** @type {PaymentLedger} */ + // @ts-expect-error could be instantiated with a different subtype + const { issuer, mint, brand, mintRecoveryPurse } = preparePaymentLedger( + issuerZone, + name, + assetKind, + cleanDisplayInfo, + elementShape, + recoverySetsState, + optShutdownWithFailure, + ); + + return harden({ + brand, + issuer, + mint, + mintRecoveryPurse, + displayInfo: cleanDisplayInfo, + }); +}; +harden(setupIssuerKit); + +/** The key at which the issuer record is stored. */ +const INSTANCE_KEY = 'issuer'; +/** + * The key at which the issuerKit's `RecoverySetsOption` state is stored. + * Introduced by an upgrade, so may be absent on a predecessor incarnation. See + * `RecoverySetsOption` for defaulting behavior. + */ +const RECOVERY_SETS_STATE = 'recoverySetsState'; + +/** + * Used _only_ to upgrade a predecessor issuerKit. Use `makeDurableIssuerKit` to + * make a new one. + * + * @template {AssetKind} K + * @param {import('@agoric/vat-data').Baggage} issuerBaggage + * @param {ShutdownWithFailure} [optShutdownWithFailure] If this issuer fails in + * the middle of an atomic action (which btw should never happen), it + * potentially leaves its ledger in a corrupted state. If this function was + * provided, then the failed atomic action will call it, so that some larger + * unit of computation, like the enclosing vat, can be shutdown before + * anything else is corrupted by that corrupted state. See + * https://github.com/Agoric/agoric-sdk/issues/3434 + * @param {RecoverySetsOption} [recoverySetsOption] Added in upgrade, so last + * and optional. See `RecoverySetsOption` for defaulting behavior. + * @returns {IssuerKit} + */ +const upgradeIssuerKit = ( + issuerBaggage, + optShutdownWithFailure = undefined, + recoverySetsOption = undefined, +) => { + const issuerRecord = issuerBaggage.get(INSTANCE_KEY); + const issuerZone = makeDurableZone(issuerBaggage); + const oldRecoverySetsState = issuerBaggage.has(RECOVERY_SETS_STATE) + ? issuerBaggage.get(RECOVERY_SETS_STATE) + : 'hasRecoverySets'; + if ( + oldRecoverySetsState === 'noRecoverySets' && + recoverySetsOption === 'hasRecoverySets' + ) { + Fail`Cannot (yet?) upgrade from 'noRecoverySets' to 'hasRecoverySets'`; + } + // Extant sets are not currently deleted. If the new option is + // 'noRecoverySets', they won't be used but extant ones will remain. Future + // upgrades may make it possible to delete elements from them. + const recoverySetsState = recoverySetsOption || oldRecoverySetsState; + return setupIssuerKit( + issuerRecord, + issuerZone, + recoverySetsState, + optShutdownWithFailure, + ); +}; +harden(upgradeIssuerKit); + +/** + * Does baggage already have an issuerKit? + * + * @param {import('@agoric/vat-data').Baggage} baggage + */ +const hasIssuer = baggage => baggage.has(INSTANCE_KEY); + +/** + * `elementShape`, may only be present for collection-style amounts. If present, + * it is a `Pattern` that every element of this issuerKits's amounts must + * satisfy. For example, the Zoe Invitation issuerKit uses an elementShape + * describing the invitation details for an individual invitation. An invitation + * purse or payment has an amount that can only be a set of these. (Though + * typically, the amount of an invitation payment is a singleton set. Such a + * payment is often referred to in the singular as "an invitation".) + * + * `recoverySetsOption` added in upgrade. Note that `IssuerOptionsRecord` is + * never stored, so we never need to worry about inheriting one from a + * predecessor predating the introduction of recovery sets. See + * `RecoverySetsOption` for defaulting behavior. + * + * @typedef {Partial<{ + * elementShape: Pattern; + * recoverySetsOption: RecoverySetsOption; + * }>} IssuerOptionsRecord + */ + +/** + * Used _only_ to make a _new_ durable issuer, i.e., the initial incarnation of + * that issuer. + * + * @template {AssetKind} K The name becomes part of the brand in asset + * descriptions. The name is useful for debugging and double-checking + * assumptions, but should not be trusted wrt any external namespace. For + * example, anyone could create a new issuer kit with name 'BTC', but it is + * not bitcoin or even related. It is only the name according to that issuer + * and brand. + * + * The assetKind will be used to import a specific mathHelpers from the + * mathHelpers library. For example, natMathHelpers, the default, is used for + * basic fungible tokens. + * + * `displayInfo` gives information to the UI on how to display the amount. + * @param {import('@agoric/vat-data').Baggage} issuerBaggage + * @param {string} name + * @param {K} [assetKind] + * @param {AdditionalDisplayInfo} [displayInfo] + * @param {ShutdownWithFailure} [optShutdownWithFailure] If this issuer fails in + * the middle of an atomic action (which btw should never happen), it + * potentially leaves its ledger in a corrupted state. If this function was + * provided, then the failed atomic action will call it, so that some larger + * unit of computation, like the enclosing vat, can be shutdown before + * anything else is corrupted by that corrupted state. See + * https://github.com/Agoric/agoric-sdk/issues/3434 + * @param {IssuerOptionsRecord} [options] + * @returns {IssuerKit} + */ +const makeDurableIssuerKit = ( + issuerBaggage, + name, + // @ts-expect-error K could be instantiated with a different subtype of AssetKind + assetKind = AssetKind.NAT, + displayInfo = harden({}), + optShutdownWithFailure = undefined, + { elementShape = undefined, recoverySetsOption = undefined } = {}, +) => { + const issuerData = harden({ + name, + assetKind, + displayInfo, + elementShape, + }); + issuerBaggage.init(INSTANCE_KEY, issuerData); + const issuerZone = makeDurableZone(issuerBaggage); + const recoverySetsState = recoverySetsOption || 'hasRecoverySets'; + issuerBaggage.init(RECOVERY_SETS_STATE, recoverySetsState); + return setupIssuerKit( + issuerData, + issuerZone, + recoverySetsState, + optShutdownWithFailure, + ); +}; +harden(makeDurableIssuerKit); + +/** + * Used to either revive a predecessor issuerKit, or to make a new durable one + * if it is absent, and to place it in baggage for the next successor. + * + * @template {AssetKind} K The name becomes part of the brand in asset + * descriptions. The name is useful for debugging and double-checking + * assumptions, but should not be trusted wrt any external namespace. For + * example, anyone could create a new issuer kit with name 'BTC', but it is + * not bitcoin or even related. It is only the name according to that issuer + * and brand. + * + * The assetKind will be used to import a specific mathHelpers from the + * mathHelpers library. For example, natMathHelpers, the default, is used for + * basic fungible tokens. + * + * `displayInfo` gives information to the UI on how to display the amount. + * @param {import('@agoric/vat-data').Baggage} issuerBaggage + * @param {string} name + * @param {K} [assetKind] + * @param {AdditionalDisplayInfo} [displayInfo] + * @param {ShutdownWithFailure} [optShutdownWithFailure] If this issuer fails in + * the middle of an atomic action (which btw should never happen), it + * potentially leaves its ledger in a corrupted state. If this function was + * provided, then the failed atomic action will call it, so that some larger + * unit of computation, like the enclosing vat, can be shutdown before + * anything else is corrupted by that corrupted state. See + * https://github.com/Agoric/agoric-sdk/issues/3434 + * @param {IssuerOptionsRecord} [options] + * @returns {IssuerKit} + */ +export const prepareIssuerKit = ( + issuerBaggage, + name, + // @ts-expect-error K could be instantiated with a different subtype of AssetKind + assetKind = AssetKind.NAT, + displayInfo = harden({}), + optShutdownWithFailure = undefined, + options = {}, +) => { + if (hasIssuer(issuerBaggage)) { + const { elementShape: _ = undefined, recoverySetsOption = undefined } = + options; + const issuerKit = upgradeIssuerKit( + issuerBaggage, + optShutdownWithFailure, + recoverySetsOption, + ); + + // TODO check consistency with name, assetKind, displayInfo, elementShape. + // Consistency either means that these are the same, or that they differ + // in a direction we are prepared to upgrade. Note that it is the + // responsibility of `upgradeIssuerKit` to check consistency of + // `recoverySetsOption`, so continue to not do that here. + + // @ts-expect-error Type parameter confusion. + return issuerKit; + } else { + const issuerKit = makeDurableIssuerKit( + issuerBaggage, + name, + assetKind, + displayInfo, + optShutdownWithFailure, + options, + ); + return issuerKit; + } +}; +harden(prepareIssuerKit); diff --git a/packages/vats/src/orch-purse/mock-orch-chain.js b/packages/vats/src/orch-purse/mock-orch-chain.js new file mode 100644 index 000000000000..56ebb4d7c714 --- /dev/null +++ b/packages/vats/src/orch-purse/mock-orch-chain.js @@ -0,0 +1,203 @@ +import { Fail } from '@endo/errors'; +import { Far } from '@endo/pass-style'; +import { M } from '@endo/patterns'; +import { provideLazy } from '@agoric/store'; + +import { AmountMath } from '@agoric/ertp'; +import { DenomAmountShape } from '@agoric/orchestration'; +import { + MinChainI, + MinChainShape, + MinDenomInfoShape, + MinOrchAccountI, + MinOrchAccountShape, + MinOrchestratorI, +} from './typeGuards.js'; + +/** + * @import {Zone} from '@agoric/base-zone' + */ + +/** + * @param {Zone} zone + */ +export const prepareMinOrchestrator = zone => { + const makeMapStore = zone.detached().mapStore; + + const makeMinOrchAccount = zone.exoClass( + 'MinOrchAccount', + MinOrchAccountI, + (chains, denoms, accounts, chain) => { + const acctAddrValue = `${accounts.size()}`; + return { chains, denoms, accounts, chain, acctAddrValue }; + }, + { + getAddress() { + const { chain, acctAddrValue } = this.state; + const { chainId } = chain.getChainInfo(); + return harden({ chainId, value: acctAddrValue }); + }, + async getBalances() { + const { accounts, acctAddrValue } = this.state; + const { balances } = accounts.get(acctAddrValue); + return [...balances.values()]; + }, + async getBalance(denom) { + const { accounts, acctAddrValue } = this.state; + const { balances } = accounts.get(acctAddrValue); + return balances.get(denom); + }, + async transfer(destAddr, denomAmount) { + const { chains, accounts, acctAddrValue } = this.state; + const { balances: myBalances } = accounts.get(acctAddrValue); + + const { denom, value: deltaValue } = denomAmount; + + const { value: myOldBalanceValue } = myBalances.get(denom); + // Would be really good to do this source check synchronously, in order + // TODO is this realistic for the real orch? + myOldBalanceValue >= deltaValue || + Fail`overdrawn ${myOldBalanceValue} - ${deltaValue}`; + const myNewBalanceValue = myOldBalanceValue - deltaValue; + + // Would be really good to do this source update synchronously, in order + // TODO is this realistic for the real orch? + myBalances.set(denom, harden({ denom, value: myNewBalanceValue })); + + await null; // sometime later... + + let destBalances; + try { + const { chainId: destChainId, value: destAcctAddrValue } = destAddr; + const { accounts: destChainAccounts } = chains.get(destChainId); + ({ balances: destBalances } = + destChainAccounts.get(destAcctAddrValue)); + } catch (err) { + // A failure at this point means the `denomAmount` is not yet spent + // at dest, and so should be restored to `myBalances`. Do so by + // adding the delta back, rather than restoring `myOldBalanceValue`, + // since the balances may have been updated in the meantime. + const { value: myNextOldBalanceValue } = myBalances.get(denom); + const myNextNewBalanceValue = myNextOldBalanceValue + deltaValue; + myBalances.set( + denom, + harden({ denom, value: myNextNewBalanceValue }), + ); + throw err; + } + if (destBalances.has(denom)) { + const { value: destOldBalanceValue } = destBalances.get(denom); + const destNewBalanceValue = destOldBalanceValue + deltaValue; + destBalances.set( + denom, + harden({ denom, value: destNewBalanceValue }), + ); + } else { + destBalances.init(denom, harden({ denom, value: deltaValue })); + } + }, + }, + ); + + const makeMinChain = zone.exoClass( + 'MinChain', + MinChainI, + (chains, denoms, chainName) => ({ + chains, + denoms, + chainId: chainName, + }), + { + async getChainInfo() { + const { chainId } = this.state; + return harden({ chainId }); + }, + async makeAccount() { + const { self } = this; + const { chains, denoms, chainId } = this.state; + const { accounts } = chains.get(chainId); + + const account = makeMinOrchAccount(chains, denoms, accounts, self); + const { value: acctAddrValue } = account.getAddress(); + accounts.init( + acctAddrValue, + harden({ + account, + balances: makeMapStore('balances', { + keyShape: M.string(), // denom + valueShape: DenomAmountShape, + }), + }), + ); + return account; + }, + async makeDenom(denom) { + const { self } = this; + const { denoms } = this.state; + // TODO make a full OrchIssuerKit + // denoms.init will error if denom is already taken + denoms.init( + denom, + harden({ + brand: Far(`fake ${denom} brand`, {}), + chain: self, + }), + ); + // TODO return orchIssuerKit + }, + }, + ); + + const makeMinOrchestrator = zone.exoClass( + 'MinOrchestrator', + MinOrchestratorI, + () => ({ + chains: makeMapStore('chains', { + keyShape: M.string(), // chainName === chainId + valueShape: { + chain: MinChainShape, + denoms: M.remotable('denoms'), + accounts: M.remotable('accounts'), + }, + }), + denoms: makeMapStore('denoms', { + keyShape: M.string(), // denom + valueShape: MinDenomInfoShape, + }), + }), + { + getChain(chainName) { + const { chains, denoms } = this.state; + const { chain } = provideLazy(chains, chainName, cName => { + const ch = makeMinChain(chains, denoms, cName); + chains.init( + chainName, + harden({ + ch, + accounts: makeMapStore('accounts', { + keyShape: M.string(), // acctAddrValue + valueShape: { + account: MinOrchAccountShape, + balances: M.remotable('balances'), + }, + }), + }), + ); + }); + return chain; + }, + getDenomInfo(denom) { + const { denoms } = this.state; + return denoms.get(denom); + }, + asAmount(denomAmount) { + const { self } = this; + const { denom, value } = denomAmount; + const { brand } = self.getDenomInfo(denom); + return AmountMath.make(brand, value); + }, + }, + ); + return makeMinOrchestrator; +}; +harden(prepareMinOrchestrator); diff --git a/packages/vats/src/orch-purse/notes.txt b/packages/vats/src/orch-purse/notes.txt new file mode 100644 index 000000000000..fd9025187d6e --- /dev/null +++ b/packages/vats/src/orch-purse/notes.txt @@ -0,0 +1,38 @@ +ub = unencumbered balance (purse "currentAmount") +eb = encumbered balance (total payments in recovery set) +fb = full balance = eb + ub (orch acct balance) + +s = source +d = dest + +ertp withdraw(a) + ub -= a + eb += a + fb unchanged + +ertp deposit(a) + sub unchanged + seb -= a + sfb -= a + once transfer succeeds + dub += a + deb unchanged + dfb += a + once transfer fails + sub += a + seb unchanged from above + sfb += a (net sfb unchanged) + +ertp burn(a) (like a successful deposit to dev/null) + ub unchanged + eb -= a + fb -= a + +ertp reclaim(a), like a successful self.deposit(a) + ub += a + eb -= a + fb unchanged + +if orch account can be independently spent from + ub can go negative, if fb < eb + payments can fail to be deposited diff --git a/packages/vats/src/orch-purse/paymentLedger.js b/packages/vats/src/orch-purse/paymentLedger.js new file mode 100644 index 000000000000..8e9f0228496a --- /dev/null +++ b/packages/vats/src/orch-purse/paymentLedger.js @@ -0,0 +1,401 @@ +// @jessie-check + +/* eslint-disable no-use-before-define */ +import { X, q, Fail, annotateError } from '@endo/errors'; +import { isPromise } from '@endo/promise-kit'; +import { mustMatch, M, keyEQ } from '@endo/patterns'; + +import { AmountMath, BrandI } from '@agoric/ertp'; +import { preparePaymentKind } from '@agoric/ertp/src/payment.js'; + +import { prepareOrchPurseKind } from './purse.js'; +import { makeOrchIssuerInterfaces } from './typeGuards.js'; + +/** + * @import {ShutdownWithFailure} from '@agoric/swingset-vat' + * @import {TypedPattern} from '@agoric/internal'; + * + * @import {Amount, AssetKind, DisplayInfo, PaymentLedger, Payment, Brand, RecoverySetsOption, Purse, Issuer, Mint} from '@agoric/ertp' + * @import {AmountStore} from '@agoric/ertp/src/amountStore.js' + */ + +/** + * @template {AssetKind} K + * @param {Brand} brand + * @param {K} assetKind + * @param {Pattern} elementShape + * @returns {TypedPattern>} + */ +const amountShapeFromElementShape = (brand, assetKind, elementShape) => { + let valueShape; + switch (assetKind) { + case 'nat': { + valueShape = M.nat(); + elementShape === undefined || + Fail`Fungible assets cannot have an elementShape: ${q(elementShape)}`; + break; + } + case 'set': { + if (elementShape === undefined) { + valueShape = M.arrayOf(M.key()); + } else { + valueShape = M.arrayOf(M.and(M.key(), elementShape)); + } + break; + } + case 'copySet': { + if (elementShape === undefined) { + valueShape = M.set(); + } else { + valueShape = M.setOf(elementShape); + } + break; + } + case 'copyBag': { + if (elementShape === undefined) { + valueShape = M.bag(); + } else { + valueShape = M.bagOf(elementShape); + } + break; + } + default: { + Fail`unexpected asset kind ${q(assetKind)}`; + } + } + + const amountShape = harden({ + brand, // matches only this exact brand + value: valueShape, + }); + return amountShape; +}; + +/** + * Make the paymentLedger, the source of truth for the balances of payments. All + * minting and transfer authority originates here. + * + * @template {AssetKind} K + * @param {import('@agoric/zone').Zone} issuerZone + * @param {string} name + * @param {K} assetKind + * @param {DisplayInfo} displayInfo + * @param {Pattern} elementShape + * @param {RecoverySetsOption} recoverySetsState + * @param {ShutdownWithFailure} [optShutdownWithFailure] + * @returns {PaymentLedger} + */ +export const preparePaymentLedger = ( + issuerZone, + name, + assetKind, + displayInfo, + elementShape, + recoverySetsState, + optShutdownWithFailure = undefined, +) => { + /** @type {Brand} */ + // @ts-expect-error XXX callWhen + const brand = issuerZone.exo(`${name} brand`, BrandI, { + isMyIssuer(allegedIssuer) { + // BrandI delays calling this method until `allegedIssuer` is a Remotable + return allegedIssuer === issuer; + }, + getAllegedName() { + return name; + }, + // Give information to UI on how to display the amount. + getDisplayInfo() { + return displayInfo; + }, + getAmountShape() { + return amountShape; + }, + }); + + const amountShape = amountShapeFromElementShape( + brand, + assetKind, + elementShape, + ); + + const { + IssuerI, + MintI, + PaymentI, + PurseIKit: OrchPurseIKit, + } = makeOrchIssuerInterfaces(brand, assetKind, amountShape); + + const makePayment = preparePaymentKind(issuerZone, name, brand, PaymentI); + + /** @type {ShutdownWithFailure} */ + const shutdownLedgerWithFailure = reason => { + // TODO This should also destroy ledger state. + // See https://github.com/Agoric/agoric-sdk/issues/3434 + if (optShutdownWithFailure !== undefined) { + try { + optShutdownWithFailure(reason); + } catch (errInShutdown) { + annotateError(errInShutdown, X`Caused by: ${reason}`); + throw errInShutdown; + } + } + throw reason; + }; + + /** @type {WeakMapStore} */ + const paymentLedger = issuerZone.weakMapStore('paymentLedger', { + valueShape: amountShape, + }); + + /** + * A (non-empty) withdrawn live payment is associated with the recovery set of + * the purse it was withdrawn from. Let's call these "recoverable" payments. + * All recoverable payments are live, but not all live payments are + * recoverable. We do the bookkeeping for payment recovery with this weakmap + * from recoverable payments to the recovery set they are in. A bunch of + * interesting invariants here: + * + * - Every payment that is a key in the outer `paymentRecoverySets` weakMap is + * also in the recovery set indexed by that payment. + * - Implied by the above but worth stating: the payment is only in at most one + * recovery set. + * - A recovery set only contains such payments. + * - Every purse is associated with exactly one recovery set unique to it. + * - A purse's recovery set only contains payments withdrawn from that purse and + * not yet consumed. + * + * If `recoverySetsState === 'noRecoverySets'`, then nothing should ever be + * added to this WeakStore. + * + * @type {WeakMapStore>} + */ + const paymentRecoverySets = issuerZone.weakMapStore('paymentRecoverySets'); + + /** + * To maintain the invariants listed in the `paymentRecoverySets` comment, + * `initPayment` should contain the only call to `paymentLedger.init`. + * + * @param {Payment} payment + * @param {Amount} amount + * @param {SetStore} [optRecoverySet] + */ + const initPayment = (payment, amount, optRecoverySet = undefined) => { + if (recoverySetsState === 'noRecoverySets') { + optRecoverySet === undefined || + Fail`when recoverSetsState === 'noRecoverySets', optRecoverySet must be empty`; + } + if (optRecoverySet !== undefined && !AmountMath.isEmpty(amount)) { + optRecoverySet.add(payment); + paymentRecoverySets.init(payment, optRecoverySet); + } + paymentLedger.init(payment, amount); + }; + + /** + * To maintain the invariants listed in the `paymentRecoverySets` comment, + * `deletePayment` should contain the only call to `paymentLedger.delete`. + * + * @param {Payment} payment + */ + const deletePayment = payment => { + paymentLedger.delete(payment); + if (paymentRecoverySets.has(payment)) { + const recoverySet = paymentRecoverySets.get(payment); + paymentRecoverySets.delete(payment); + recoverySet.delete(payment); + } + }; + + /** @type {(allegedAmount: Amount) => Amount} */ + const coerce = allegedAmount => AmountMath.coerce(brand, allegedAmount); + /** @type {(left: Amount, right: Amount) => boolean} */ + + /** + * Methods like deposit() have an optional second parameter `optAmountShape` + * which, if present, is supposed to match the balance of the payment. This + * helper function does that check. + * + * Note: `optAmountShape` is user-supplied with no previous validation. + * + * @param {Amount} paymentBalance + * @param {Pattern} [optAmountShape] + * @returns {void} + */ + const assertAmountConsistent = (paymentBalance, optAmountShape) => { + if (optAmountShape !== undefined) { + mustMatch(paymentBalance, optAmountShape, 'amount'); + } + }; + + /** + * @param {Payment} payment + * @returns {void} + */ + const assertLivePayment = payment => { + paymentLedger.has(payment) || + Fail`${payment} was not a live payment for brand ${q( + brand, + )}. It could be a used-up payment, a payment for another brand, or it might not be a payment at all.`; + }; + + /** + * Used by the purse code to implement purse.deposit + * + * @param {AmountStore} balanceStore + * @param {Payment} srcPayment + * @param {Pattern} [optAmountShape] + * @returns {ERef} + */ + const depositInternal = ( + balanceStore, + srcPayment, + optAmountShape = undefined, + ) => { + !isPromise(srcPayment) || + assert.fail( + `deposit does not accept promises as first argument. Instead of passing the promise (deposit(paymentPromise)), consider unwrapping the promise first: E.when(paymentPromise, (actualPayment => deposit(actualPayment))`, + TypeError, + ); + assertLivePayment(srcPayment); + const srcPaymentBalance = paymentLedger.get(srcPayment); + assertAmountConsistent(srcPaymentBalance, optAmountShape); + try { + // COMMIT POINT + // Move the assets in `srcPayment` into this purse, using up the + // source payment, such that total assets are conserved. + deletePayment(srcPayment); + balanceStore.increment(srcPaymentBalance); + } catch (err) { + shutdownLedgerWithFailure(err); + throw err; + } + return srcPaymentBalance; + }; + + /** + * Used by the purse code to implement purse.withdraw + * + * @param {AmountStore} balanceStore + * @param {Amount} amount - the amount to be withdrawn + * @param {SetStore} [recoverySet] + * @returns {Payment} + */ + const withdrawInternal = (balanceStore, amount, recoverySet = undefined) => { + amount = coerce(amount); + const payment = makePayment(); + // COMMIT POINT Move the withdrawn assets from this purse into + // payment. Total assets must remain conserved. + balanceStore.decrement(amount) || + Fail`Withdrawal of ${amount} failed because the purse only contained ${balanceStore.getAmount()}`; + try { + initPayment(payment, amount, recoverySet); + } catch (err) { + shutdownLedgerWithFailure(err); + throw err; + } + return payment; + }; + + /** @type {() => Purse} */ + // @ts-expect-error XXX amount kinds + const makeEmptyPurse = prepareOrchPurseKind( + issuerZone, + name, + assetKind, + brand, + OrchPurseIKit, + harden({ + depositInternal, + withdrawInternal, + }), + recoverySetsState, + paymentRecoverySets, + ); + + /** @type {Issuer} */ + // @ts-expect-error XXX callWhen + const issuer = issuerZone.exo(`${name} issuer`, IssuerI, { + getBrand() { + return brand; + }, + getAllegedName() { + return name; + }, + getAssetKind() { + return assetKind; + }, + getDisplayInfo() { + return displayInfo; + }, + makeEmptyPurse() { + return makeEmptyPurse(); + }, + /** @param {Payment} payment awaited by callWhen */ + isLive(payment) { + // IssuerI delays calling this method until `payment` is a Remotable + return paymentLedger.has(payment); + }, + /** @param {Payment} payment awaited by callWhen */ + getAmountOf(payment) { + // IssuerI delays calling this method until `payment` is a Remotable + assertLivePayment(payment); + return paymentLedger.get(payment); + }, + /** + * @param {Payment} payment awaited by callWhen + * @param {Pattern} optAmountShape + */ + burn(payment, optAmountShape = undefined) { + // IssuerI delays calling this method until `payment` is a Remotable + assertLivePayment(payment); + const paymentBalance = paymentLedger.get(payment); + assertAmountConsistent(paymentBalance, optAmountShape); + try { + // COMMIT POINT. + deletePayment(payment); + } catch (err) { + shutdownLedgerWithFailure(err); + throw err; + } + return paymentBalance; + }, + }); + + /** + * Provides for the recovery of newly minted but not-yet-deposited payments. + * + * Because the `mintRecoveryPurse` is placed in baggage, even if the caller of + * `makeIssuerKit` drops it on the floor, it can still be recovered in an + * emergency upgrade. + */ + const mintRecoveryPurse = /** @type {Purse} */ ( + issuerZone.makeOnce('mintRecoveryPurse', () => makeEmptyPurse()) + ); + + /** @type {Mint} */ + const mint = issuerZone.exo(`${name} mint`, MintI, { + getIssuer() { + return issuer; + }, + mintPayment(newAmount) { + newAmount = coerce(newAmount); + mustMatch(newAmount, amountShape, 'minted amount'); + // `rawPayment` is not associated with any recovery set, and + // so must not escape. + const rawPayment = makePayment(); + initPayment(rawPayment, newAmount, undefined); + + const mintRecoveryPurseBefore = mintRecoveryPurse.getCurrentAmount(); + mintRecoveryPurse.deposit(rawPayment, newAmount); + const payment = mintRecoveryPurse.withdraw(newAmount); + const mintRecoveryPurseAfter = mintRecoveryPurse.getCurrentAmount(); + assert(keyEQ(mintRecoveryPurseBefore, mintRecoveryPurseAfter)); + return payment; + }, + }); + + const issuerKit = harden({ issuer, mint, brand, mintRecoveryPurse }); + return issuerKit; +}; +harden(preparePaymentLedger); diff --git a/packages/vats/src/orch-purse/purse.js b/packages/vats/src/orch-purse/purse.js new file mode 100644 index 000000000000..f0c38155b5b9 --- /dev/null +++ b/packages/vats/src/orch-purse/purse.js @@ -0,0 +1,190 @@ +import { Fail } from '@endo/errors'; +import { M, makeCopySet } from '@agoric/store'; + +import { AmountMath } from '@agoric/ertp'; +import { makeAmountStore } from '@agoric/ertp/src/amountStore.js'; +import { makeTransientNotifierKit } from '@agoric/ertp/src/transientNotifier.js'; + +/** + * @import {AssetKind, RecoverySetsOption, Brand, Payment} from '@agoric/ertp' + * + * @import {MinChain} from './types.js' + */ + +const EMPTY_COPY_SET = makeCopySet([]); + +// TODO Type InterfaceGuard better than InterfaceGuard +/** + * @param {import('@agoric/zone').Zone} issuerZone + * @param {MinChain} orchChain + * @param {string} name + * @param {AssetKind} assetKind + * @param {Brand} brand + * @param {{ + * purse: import('@endo/patterns').InterfaceGuard; + * depositFacet: import('@endo/patterns').InterfaceGuard; + * }} OrchPurseIKit + * @param {{ + * depositInternal: any; + * withdrawInternal: any; + * }} purseMethods + * @param {RecoverySetsOption} recoverySetsState + * @param {WeakMapStore>} paymentRecoverySets + */ +export const prepareOrchPurseKind = ( + issuerZone, + orchChain, + name, + assetKind, + brand, + OrchPurseIKit, + purseMethods, + recoverySetsState, + paymentRecoverySets, +) => { + const amountShape = brand.getAmountShape(); + + // Note: Virtual for high cardinality, but *not* durable, and so + // broken across an upgrade. + // TODO propagate zonifying to notifiers, maybe? + const { provideNotifier, update: updateBalance } = makeTransientNotifierKit(); + + /** + * If `recoverySetsState === 'hasRecoverySets'` (the normal state), then just + * return `state.recoverySet`. + * + * If `recoverySetsState === 'noRecoverySets'`, return `undefined`. Callers + * must be aware that the `undefined` return happens iff `recoverySetsState + * === 'noRecoverySets'`, and to avoid storing or retrieving anything from the + * actual recovery set. + * + * @param {{ recoverySet: SetStore }} state + * @returns {SetStore | undefined} + */ + const maybeRecoverySet = state => { + const { recoverySet } = state; + if (recoverySetsState === 'hasRecoverySets') { + return recoverySet; + } else { + recoverySetsState === 'noRecoverySets' || + Fail`recoverSetsState must be noRecoverySets if it isn't hasRecoverSets`; + paymentRecoverySets !== undefined || + Fail`paymentRecoverySets must always be defined`; + recoverySet.getSize() === 0 || + Fail`With noRecoverySets, recoverySet must be empty`; + + return undefined; + } + }; + + // - This kind is a pair of purse and depositFacet that have a 1:1 + // correspondence. + // - They are virtualized together to share a single state record. + // - An alternative design considered was to have this return a Purse alone + // that created depositFacet as needed. But this approach ensures a constant + // identity for the facet and exercises the multi-faceted object style. + const { depositInternal, withdrawInternal } = purseMethods; + const makeOrchPurseKit = issuerZone.exoClassKit( + `${name} OrchPurse`, + OrchPurseIKit, + () => { + const orchAccount = orchChain.makeAccount(); + + /** @type {SetStore} */ + const recoverySet = issuerZone.detached().setStore('recovery set'); + + return { + orchAccount, + recoverySet, + }; + }, + { + purse: { + deposit(srcPayment, optAmountShape = undefined) { + // PurseI does *not* delay `deposit` until `srcPayment` is fulfulled. + // See the comments on PurseI.deposit in typeGuards.js + const { state } = this; + const { purse } = this.facets; + const balanceStore = makeAmountStore(state, 'currentBalance'); + // Note COMMIT POINT within deposit. + const srcPaymentBalance = depositInternal( + balanceStore, + srcPayment, + optAmountShape, + ); + updateBalance(purse, balanceStore.getAmount()); + return srcPaymentBalance; + }, + withdraw(amount) { + const { state } = this; + const { purse } = this.facets; + + const optRecoverySet = maybeRecoverySet(state); + const balanceStore = makeAmountStore(state, 'currentBalance'); + // Note COMMIT POINT within withdraw. + const payment = withdrawInternal( + balanceStore, + amount, + optRecoverySet, + ); + updateBalance(purse, balanceStore.getAmount()); + return payment; + }, + getCurrentAmount() { + const { state } = this; + const balanceStore = makeAmountStore(state, 'currentBalance'); + return balanceStore.getAmount(); + }, + getCurrentAmountNotifier() { + return provideNotifier(this.facets.purse); + }, + getAllegedBrand() { + return brand; + }, + + getDepositFacet() { + return this.facets.depositFacet; + }, + + getRecoverySet() { + const { state } = this; + const optRecoverySet = maybeRecoverySet(state); + if (optRecoverySet === undefined) { + return EMPTY_COPY_SET; + } + return optRecoverySet.snapshot(); + }, + recoverAll() { + const { state, facets } = this; + let amount = AmountMath.makeEmpty(brand, assetKind); + const optRecoverySet = maybeRecoverySet(state); + if (optRecoverySet === undefined) { + return amount; // empty at this time + } + for (const payment of optRecoverySet.keys()) { + // This does cause deletions from the set while iterating, + // but this special case is allowed. + const delta = facets.purse.deposit(payment); + amount = AmountMath.add(amount, delta, brand); + } + optRecoverySet.getSize() === 0 || + Fail`internal: Remaining unrecovered payments: ${facets.purse.getRecoverySet()}`; + return amount; + }, + }, + depositFacet: { + receive(...args) { + return this.facets.purse.deposit(...args); + }, + }, + }, + { + stateShape: { + currentBalance: amountShape, + recoverySet: M.remotable('recoverySet'), + }, + }, + ); + return () => makeOrchPurseKit().purse; +}; +harden(prepareOrchPurseKind); diff --git a/packages/vats/src/orch-purse/typeGuards.js b/packages/vats/src/orch-purse/typeGuards.js new file mode 100644 index 000000000000..9ed6794074a2 --- /dev/null +++ b/packages/vats/src/orch-purse/typeGuards.js @@ -0,0 +1,126 @@ +// @jessie-check + +import { M, getInterfaceGuardPayload } from '@endo/patterns'; + +import { + AmountPatternShape, + AmountShape, + AssetKindShape, + BrandShape, + makeIssuerInterfaces, + PaymentShape, +} from '@agoric/ertp'; +import { + ChainInfoShape, + DenomShape, + DenomAmountShape, +} from '@agoric/orchestration'; + +// //////////////////////// Orchestration-like ///////////////////////////////// + +export const MinOrchAccountShape = M.remotable('MinOrchAccount'); +export const MinChainShape = M.remotable('MinChain'); +export const MinOrchestratorShape = M.remotable('MinOrchestrator'); + +/** + * @see {ChainAddressShape} + * @see {MinChainAcctAddr} + */ +export const MinChainAcctAddrShape = harden({ + chainId: M.string(), + value: M.string(), // acctAddrValue +}); + +/** + * @see {orchestrationAccountMethods} + * @see {MinOrchAccount} + */ +export const MinOrchAccountI = M.interface('MinOrchAccount', { + getAddress: M.call().returns(MinChainAcctAddrShape), + getBalances: M.call().returns(M.eref(M.arrayOf(DenomAmountShape))), + getBalance: M.call(M.string()).returns(M.eref(DenomAmountShape)), + transfer: M.call(MinChainAcctAddrShape, DenomAmountShape).returns( + M.eref(M.undefined()), + ), +}); + +/** + * @see {DenomInfoShape} + * @see {MinDenomInfo} + */ +export const MinDenomInfoShape = harden({ + brand: BrandShape, + chain: MinChainShape, +}); + +/** + * @see {chainFacadeMethods} + * @see {MinChain} + * @see {OrchestratorI} + */ +export const MinChainI = M.interface('MinChain', { + getChainInfo: M.call().returns(M.eref(ChainInfoShape)), + makeAccount: M.call().returns(M.eref(MinOrchAccountShape)), + + // TODO returns(M.eref(OrchIssuerKitShape)) + makeDenom: M.call(DenomShape).returns(M.eref(M.any())), +}); + +/** + * @see {OrchestratorI} + * @see {MinOrchestrator} + */ +export const MinOrchestratorI = M.interface('MinOrchestrator', { + getChain: M.call(M.string()).returns(M.eref(MinChainShape)), + getDenomInfo: M.call(DenomShape).returns(MinDenomInfoShape), + asAmount: M.call(DenomAmountShape).returns(AmountShape), +}); + +// //////////////////////// ERTP-like ////////////////////////////////////////// + +/** + * @param {Pattern} [brandShape] + * @param {Pattern} [assetKindShape] + * @param {Pattern} [amountShape] + */ +export const makeOrchIssuerInterfaces = ( + brandShape = BrandShape, + assetKindShape = AssetKindShape, + amountShape = AmountShape, +) => { + const { + IssuerI, + MintI, + PaymentI, + PurseIKit: { purse: OriginalPurseI }, + } = makeIssuerInterfaces(brandShape, assetKindShape, amountShape); + + const { methodGuards: originalPurseMethodGuards } = + getInterfaceGuardPayload(OriginalPurseI); + + const OrchPurseI = M.interface('OrchPurse', { + ...originalPurseMethodGuards, + getCurrentAmount: M.call().returns(M.eref(amountShape)), + getCurrentFullBalance: M.call().returns(M.eref(amountShape)), + deposit: M.call(PaymentShape) + .optional(AmountPatternShape) + .returns(M.eref(amountShape)), + }); + + const OrchDepositFacetI = M.interface('OrchDepositFacet', { + receive: getInterfaceGuardPayload(OrchPurseI).methodGuards.deposit, + }); + + const OrchPurseIKit = harden({ + purse: OrchPurseI, + depositFacet: OrchDepositFacetI, + }); + + return harden({ + IssuerI, + MintI, + PaymentI, + PurseIKit: OrchPurseIKit, + }); +}; +harden(makeOrchIssuerInterfaces); diff --git a/packages/vats/src/orch-purse/types.ts b/packages/vats/src/orch-purse/types.ts new file mode 100644 index 000000000000..cb097d10ed0c --- /dev/null +++ b/packages/vats/src/orch-purse/types.ts @@ -0,0 +1,160 @@ +import type { ERef } from '@endo/far'; +import type { CopySet, Key, Pattern } from '@endo/patterns'; +import type { LatestTopic } from '@agoric/notifier'; + +import type { Amount, Brand, Payment } from '@agoric/ertp'; +import type { Denom, DenomAmount, ChainAddress } from '@agoric/orchestration'; + +// //////////////////////// Orchestration-like ///////////////////////////////// + +/** + * @see {ChainAddress}, which actually names an account on a chain. + * Like a widely-held deposit-facet. + */ +export type MinChainAcctAddr = { + chainId: string; + value: string; // acctAddrValue +}; + +/** + * @see {OrchestrationAccount} + */ +export type MinOrchAccount = { + getAddress(): MinChainAcctAddr; + getBalances(): ERef; + getBalance(denom: Denom): ERef; + transfer(destAddr: MinChainAcctAddr, denomAmount: DenomAmount): ERef; +}; + +/** + * @see {ChainInfo}, which is in the intersection of the existing + * `ChainInfo` possibilities. + */ +export type MinChainInfo = { + chainId: string; +}; + +/** + * @see {DenomInfo} + */ +export type MinDenomInfo = { + brand: Brand; + // eslint-disable-next-line no-use-before-define + chain: MinChain; +}; + +/** + * @see {Chain} + */ +export type MinChain = { + getChainInfo(): ERef; + makeAccount(): ERef; + + makeDenom(denom: Denom): ERef; // TODO OrchIssuerKit +}; + +/** + * @see {Orchestrator} + */ +export type MinOrchestrator = { + getChain(chainName: string): ERef; + getDenomInfo(denom: Denom): MinDenomInfo; + asAmount(denomAmount: DenomAmount): Amount; +}; + +// //////////////////////// ERTP-like ////////////////////////////////////////// + +export type OrchDepositFacetReceive = ( + payment: Payment, + optAmountShape?: Pattern, +) => ERef; + +export type OrchDepositFacet = { + /** + * Deposit all the contents of payment + * into the purse that made this facet, returning the amount. If the optional + * argument `optAmount` does not equal the amount of digital assets in the + * payment, throw an error. + * + * If payment is a promise, throw an error. + */ + receive: OrchDepositFacetReceive; +}; + +/** + * Purses hold amount of + * digital assets of the same brand, but unlike Payments, they are not meant + * to be sent to others. To transfer digital assets, a Payment should be + * withdrawn from a Purse. The amount of digital assets in a purse can change + * through the action of deposit() and withdraw(). + */ +export type OrchPurse = { + /** + * Get the alleged Brand for this + * Purse + */ + getAllegedBrand: () => Brand; + + /** + * Get the amount contained in + * this purse. + */ + getCurrentAmount: () => ERef; + + /** + * Get the amount contained in + * this purse + all payments still in the recoverySet at this moment + */ + getCurrentFullBalance: () => ERef; + + /** + * Get a + * lossy notifier for changes to this purse's balance. + */ + getCurrentAmountNotifier: () => LatestTopic; + + /** + * Deposit all the contents of payment into this purse, returning the amount. If + * the optional argument `optAmount` does not equal the amount of digital + * assets in the payment, throw an error. + * + * If payment is a promise, throw an error. + */ + deposit: (payment: Payment, optAmountShape?: Pattern) => ERef; + + /** + * Return an object whose + * `receive` method deposits to the current Purse. + */ + getDepositFacet: () => OrchDepositFacet; + + /** + * Withdraw amount + * from this purse into a new Payment. + */ + withdraw: (amount: Amount) => Payment; + + /** + * The set of payments + * withdrawn from this purse that are still live. These are the payments that + * can still be recovered in emergencies by, for example, depositing into this + * purse. Such a deposit action is like canceling an outstanding check because + * you're tired of waiting for it. Once your cancellation is acknowledged, you + * can spend the assets at stake on other things. Afterwards, if the recipient + * of the original check finally gets around to depositing it, their deposit + * fails. + * + * Returns an empty set if this issuer does not support recovery sets. + */ + getRecoverySet: () => CopySet; + + /** + * For use in emergencies, such as + * coming back from a traumatic crash and upgrade. This deposits all the + * payments in this purse's recovery set into the purse itself, returning the + * total amount of assets recovered. + * + * Returns an empty amount if this issuer does not support recovery sets. + */ + recoverAll: () => Amount; +}; diff --git a/packages/vats/test/orch-purse/mock-orch-chain.test.js b/packages/vats/test/orch-purse/mock-orch-chain.test.js new file mode 100644 index 000000000000..2fcf3ea083a2 --- /dev/null +++ b/packages/vats/test/orch-purse/mock-orch-chain.test.js @@ -0,0 +1,14 @@ +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; + +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareMinOrchestrator } from '../../src/orch-purse/mock-orch-chain.js'; + +test('mock orch chain', t => { + const baggage = makeScalarBigMapStore('test-baggage', { durable: true }); + const zone = makeDurableZone(baggage); + const makeMinOrchestrator = prepareMinOrchestrator(zone); + const minOrchestrator = makeMinOrchestrator(); + t.truthy(minOrchestrator); +});