From 61d284877c8119e5365a104b0249e113ca627b85 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Sat, 9 Nov 2024 12:08:31 -0800 Subject: [PATCH] feat(vats): Mock ERTP face on mock orch assets --- packages/orchestration/package.json | 1 + .../mock-ertp/internal-typeGuards.js | 95 ++++ .../mock-ertp/internal-types.ts | 56 +++ .../src/mock-orch-ertp/mock-ertp/mock-ertp.js | 448 ++++++++++++++++++ .../mock-orch-ertp/mock-ertp/typeGuards.js | 80 ++++ .../src/mock-orch-ertp/mock-ertp/types.ts | 122 +++++ .../mock-orch/internal-typeGuards.js | 55 +++ .../mock-orch/internal-types.ts | 49 ++ .../src/mock-orch-ertp/mock-orch/mock-orch.js | 243 ++++++++++ .../mock-orch-ertp/mock-orch/typeGuards.js | 65 +++ .../src/mock-orch-ertp/mock-orch/types.ts | 69 +++ .../src/mock-orch-ertp/notes.txt | 43 ++ packages/orchestration/test/facade.test.ts | 1 - .../mock-ertp/mock-ertp.test.js | 49 ++ .../mock-orch/mock-orch.test.js | 53 +++ 15 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-typeGuards.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-types.ts create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-ertp/mock-ertp.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-ertp/typeGuards.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-ertp/types.ts create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-orch/internal-typeGuards.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-orch/internal-types.ts create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-orch/mock-orch.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-orch/typeGuards.js create mode 100644 packages/orchestration/src/mock-orch-ertp/mock-orch/types.ts create mode 100644 packages/orchestration/src/mock-orch-ertp/notes.txt create mode 100644 packages/orchestration/test/mock-orch-ertp/mock-ertp/mock-ertp.test.js create mode 100644 packages/orchestration/test/mock-orch-ertp/mock-orch/mock-orch.test.js diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index f104ad60ad6..b3bc58e42c3 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -48,6 +48,7 @@ "@agoric/zone": "^0.2.2", "@endo/base64": "^1.0.9", "@endo/errors": "^1.2.8", + "@endo/eventual-send": "^1.2.8", "@endo/far": "^1.1.9", "@endo/marshal": "^1.6.2", "@endo/patterns": "^1.4.7", diff --git a/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-typeGuards.js b/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-typeGuards.js new file mode 100644 index 00000000000..e591a4cf7b1 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-typeGuards.js @@ -0,0 +1,95 @@ +import { M } from '@endo/patterns'; + +import { + AmountShape, + BrandShape, + IssuerKitShape, + PaymentShape, + PurseShape, +} from '@agoric/ertp'; +import { AnyNatAmountShape, DenomShape } from '../../typeGuards.js'; +import { mockIssuerInterfaces } from './typeGuards.js'; +import { MockOrchAccountShape } from '../mock-orch/typeGuards.js'; + +export const IssuerAdminShape = M.remotable('IssuerAdmin'); +export const RecoverySetShape = M.remotable('RecoverySet'); +export const RecoveryFacetShape = M.remotable('RecoverFacet'); +export const Denom2IssuerKitShape = M.remotable('Denom2IssuerKit'); +export const Brand2DenomShape = M.remotable('Brand2Denom'); + +const { + mintRecoveryPurse: _1, // omit + displayInfo: _2, // omit + ...CoreIssuerKitShape +} = IssuerKitShape; + +export const IssuerKitPlusShape = harden({ + ...CoreIssuerKitShape, + admin: IssuerAdminShape, +}); + +export const PaymentLedgerEntryShape = harden({ + keyShape: PaymentShape, + valueShape: AnyNatAmountShape, +}); + +export const PaymentRecoveryEntryShape = harden({ + keyShape: PaymentShape, + valueShape: RecoveryFacetShape, +}); + +export const Denom2IssuerKitEntryShape = harden({ + keyShape: DenomShape, + valueShape: IssuerKitPlusShape, +}); + +export const Brand2DenomEntryShape = harden({ + keyShape: BrandShape, + valueShape: DenomShape, +}); + +/** + * @param {Pattern} [brandShape] + * @param {Pattern} [assetKindShape] + * @param {Pattern} [amountShape] + */ +export const mockIssuerInterfacesPlus = ( + brandShape = undefined, + assetKindShape = undefined, + amountShape = AmountShape, +) => { + const { + IssuerI, + MintI, + PaymentI, + PurseIKit: MockPurseIKit, + } = mockIssuerInterfaces(brandShape, assetKindShape, amountShape); + + const RecoveryFacetI = M.interface('RecoveryFacet', { + initPayment: M.call(PaymentShape).returns(), + deletePayment: M.call(PaymentShape).returns(), + getRecoverySetStore: M.call().returns(RecoverySetShape), + getCurrentEncumberedBalance: M.call().returns(amountShape), + encumber: M.call(amountShape).returns(), + unencumber: M.call(amountShape).returns(), + getOrchAcct: M.call().returns(M.eref(MockOrchAccountShape)), + }); + + const MockPurseIKitPlus = { + ...MockPurseIKit, + recoveryFacet: RecoveryFacetI, + }; + + const IssuerAdminI = M.interface('MockIssuerAdmin', { + makePurse: M.call(M.eref(MockOrchAccountShape)).returns(M.eref(PurseShape)), + }); + + return harden({ + IssuerI, + MintI, + PaymentI, + PurseIKit: MockPurseIKitPlus, + IssuerAdminI, + }); +}; +harden(mockIssuerInterfacesPlus); diff --git a/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-types.ts b/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-types.ts new file mode 100644 index 00000000000..40f952407d7 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-ertp/internal-types.ts @@ -0,0 +1,56 @@ +import type { ERef } from '@endo/eventual-send'; + +import type { WeakMapStore, SetStore } from '@agoric/store'; + +import type { Payment, Amount } from '@agoric/ertp'; +import type { MockOrchAccount } from '../mock-orch/types.ts'; +import type { MockPurse } from './types.js'; + +export type PaymentLedgerMap = WeakMapStore; + +export type RecoverySet = SetStore; + +export type RecoveryFacet = { + /** + * + */ + initPayment: (payment: Payment) => void; + + /** + * + */ + deletePayment: (payment: Payment) => void; + + /** + * Awkward name because `getRecoverSet` is already a method of purse that + * return a copySet. + */ + getRecoverySetStore: () => RecoverySet; + + /** + * Get the amount contained in all payments still in the recoverySet at + * this moment + */ + getCurrentEncumberedBalance: () => Amount; + + /** + * + */ + encumber: (amount: Amount) => void; + + /** + * + */ + unencumber: (amount: Amount) => void; + + /** + * + */ + getOrchAcct: () => ERef; +}; + +export type PaymentRecoveryMap = WeakMapStore; + +export type MockIssuerAdmin = { + makePurse: (orchAcctP: ERef) => ERef; +}; diff --git a/packages/orchestration/src/mock-orch-ertp/mock-ertp/mock-ertp.js b/packages/orchestration/src/mock-orch-ertp/mock-ertp/mock-ertp.js new file mode 100644 index 00000000000..33dc4d0cd90 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-ertp/mock-ertp.js @@ -0,0 +1,448 @@ +import { Fail, q } from '@endo/errors'; +import { E } from '@endo/eventual-send'; +import { mustMatch } from '@endo/patterns'; + +import { AmountMath, BrandI, PaymentShape } from '@agoric/ertp'; +import { AnyNatAmountShape } from '../../typeGuards.js'; +import { + mockIssuerInterfacesPlus, + PaymentLedgerEntryShape, + PaymentRecoveryEntryShape, + Brand2DenomEntryShape, + Denom2IssuerKitEntryShape, +} from './internal-typeGuards.js'; + +/** + * @import {ERef} from '@endo/eventual-send' + * @import {Amplify} from '@endo/exo' + * + * @import {WeakMapStore} from '@agoric/store' + * @import {Zone} from '@agoric/zone' + * @import {Brand, Amount, Payment} from '@agoric/ertp' + * @import {Denom, DenomAmount} from '../../orchestration-api.js' + * @import {MockOrchestratorAdmin, MockDenomMint} from '../mock-orch/internal-types.js' + * @import {MockOrchestrator, MockChain, MockOrchAccount} from '../mock-orch/types.js' + * @import {PaymentLedgerMap, RecoveryFacet, PaymentRecoveryMap} from './internal-types.js' + * @import {MockPurse} from './types.js' + */ + +const { + IssuerI, + MintI, + PaymentI, + PurseIKit: MockPurseIKitPlus, + IssuerAdminI, +} = mockIssuerInterfacesPlus(); + +/** + * @param {Zone} zone + */ +export const prepareERTPOrchestrator = zone => { + const makeWeakMapStore = zone.detached().weakMapStore; + const makeSetStore = zone.detached().setStore; + + const mockPayment = zone.exoClass( + 'MockPayment', + PaymentI, + brand => ({ brand }), + { + getAllegedBrand() { + return this.state.brand; + }, + }, + ); + + /** @type {Amplify} */ + let purseKitAmp; + + const mockPurseKit = zone.exoClassKit( + 'MockPurse', + MockPurseIKitPlus, + /** + * @param {MockOrchAccount} orchAcct + * @param {Denom} denom + * @param {Brand} brand + * @param {PaymentLedgerMap} paymentLedger + * @param {WeakMapStore} paymentRecoveryFacets + */ + (orchAcct, denom, brand, paymentLedger, paymentRecoveryFacets) => { + const recoverySet = makeSetStore('recoverySet', { + keyShape: PaymentShape, + }); + const initialEncumberedBalance = /** @type {Amount} */ ( + harden({ + brand, + value: 0n, + }) + ); + return { + orchAcct, + denom, + brand, + paymentLedger, + paymentRecoveryFacets, + recoverySet, + currentEncumberedBalance: initialEncumberedBalance, + }; + }, + { + purse: { + getAllegedBrand() { + return this.state.brand; + }, + async getCurrentFullBalance() { + const { orchAcct, denom, brand } = this.state; + const { value } = await E(orchAcct).getBalance(denom); + return /** @type {Amount} */ (harden({ brand, value })); + }, + getCurrentEncumberedBalance() { + return this.state.currentEncumberedBalance; + }, + async getCurrentUnencumberedBalance() { + const { purse } = this.facets; + const fullBalance = await purse.getCurrentFullBalance(); + const encBalance = purse.getCurrentEncumberedBalance(); + AmountMath.isGTE(fullBalance, encBalance) || + Fail`unencumbered balance is negative`; + return AmountMath.subtract(fullBalance, encBalance); + }, + // alias for getCurrentUnencumberedBalance + async getCurrentAmount() { + return this.facets.purse.getCurrentUnencumberedBalance(); + }, + async getCurrentAmountNotifier() { + Fail`Mock ertp does not implement amount/balance notifiers`; + }, + async deposit(payment, optAmountShape = undefined) { + const { orchAcct, denom, paymentLedger, paymentRecoveryFacets } = + this.state; + + const recoveryFacet = paymentRecoveryFacets.get(payment); + const paymentAmount = paymentLedger.get(payment); + if (optAmountShape !== undefined) { + mustMatch(paymentAmount, optAmountShape); + } + const denomAmount = /** @type {DenomAmount} */ ( + harden({ + denom, + value: paymentAmount.value, + }) + ); + + // COMMIT 1 + + // TODO Should we move the unencumber up here? + // recoveryFacet.unencumber(paymentAmount); + + recoveryFacet.deletePayment(payment); + + const srcOrchAcct = await recoveryFacet.getOrchAcct(); + const destAddr = await E(orchAcct).getAddress(); + try { + await E(srcOrchAcct).transfer(destAddr, denomAmount); + recoveryFacet.unencumber(paymentAmount); + return paymentAmount; + } catch (err) { + recoveryFacet.unencumber(paymentAmount); + throw err; + } + }, + async withdraw(amount) { + // No overdrawn prevention on withdraw. Only deposit. + // Surprising, but probably correct! + const { brand, paymentLedger } = this.state; + const { recoveryFacet } = this.facets; + + const payment = mockPayment(brand); + if (!AmountMath.isEmpty(amount)) { + recoveryFacet.initPayment(payment); + recoveryFacet.encumber(amount); + } + paymentLedger.init(payment, amount); + return payment; + }, + getDepositFacet() { + return this.facets.depositFacet; + }, + getRecoverySet() { + return this.state.recoverySet.snapshot(); + }, + recoverAll() { + const { + brand, + recoverySet, + currentEncumberedBalance: value, + } = this.state; + const { recoveryFacet } = this.facets; + for (const payment of recoverySet.keys()) { + recoveryFacet.deletePayment(payment); + } + this.state.currentEncumberedBalance = /** @type {Amount} */ ( + harden({ + brand, + value: 0n, + }) + ); + return harden({ brand, value }); + }, + }, + depositFacet: { + async receive(payment, optAmountShape) { + return this.facets.purse.deposit(payment, optAmountShape); + }, + }, + recoveryFacet: { + initPayment(payment) { + const { paymentRecoveryFacets, recoverySet } = this.state; + const { recoveryFacet } = this.facets; + + recoverySet.add(payment); + paymentRecoveryFacets.init(payment, recoveryFacet); + }, + deletePayment(payment) { + const { paymentLedger, paymentRecoveryFacets, recoverySet } = + this.state; + + paymentRecoveryFacets.delete(payment); + recoverySet.delete(payment); + paymentLedger.delete(payment); + }, + getRecoverySetStore() { + return this.state.recoverySet; + }, + getCurrentEncumberedBalance() { + return this.state.currentEncumberedBalance; + }, + encumber(amount) { + const { currentEncumberedBalance: oldEncBalance } = this.state; + this.state.currentEncumberedBalance = AmountMath.add( + oldEncBalance, + amount, + ); + }, + unencumber(amount) { + const { currentEncumberedBalance: oldEncBalance } = this.state; + this.state.currentEncumberedBalance = AmountMath.subtract( + oldEncBalance, + amount, + ); + }, + getOrchAcct() { + return this.state.orchAcct; + }, + }, + }, + { + receiveAmplifier(a) { + purseKitAmp = a; + }, + }, + ); + + // @ts-expect-error TS thinks it is used before assigned, which is a hazard + // TS is correct to bring to our attention, since there is not enough static + // into to infer otherwise. + assert(purseKitAmp !== undefined); + + const mockIssuerKit = zone.exoClassKit( + 'MockIssuerKit', + { + brand: BrandI, + issuer: IssuerI, + mint: MintI, + admin: IssuerAdminI, + }, + /** + * @param {Denom} denom + * @param {ERef} chain + * @param {ERef} [optDenomMint] + */ + (denom, chain, optDenomMint = undefined) => { + /** @type {PaymentLedgerMap} */ + const paymentLedger = makeWeakMapStore( + 'paymentLedger', + PaymentLedgerEntryShape, + ); + /** @type {PaymentRecoveryMap} */ + const paymentRecoveryFacets = makeWeakMapStore( + 'paymentRecoverFacets', + PaymentRecoveryEntryShape, + ); + return { + denom, + chain, + paymentLedger, + paymentRecoveryFacets, + optDenomMint, + optMintRecoveryPurse: /** @type {MockPurse | undefined} */ (undefined), + }; + }, + { + brand: { + isMyIssuer(allegedIssuer) { + return this.facets.issuer === allegedIssuer; + }, + getAllegedName() { + return this.state.denom; + }, + getAmountShape() { + return AnyNatAmountShape; + }, + getDisplayInfo() { + Fail`mock ertp does not implement displayInfo`; + }, + }, + issuer: { + getBrand() { + return this.facets.brand; + }, + getAllegedName() { + return this.state.denom; + }, + getAssetKind() { + return 'nat'; + }, + getDisplayInfo() { + Fail`mock ertp does not implement deprecated getDisplayInfo`; + }, + async makeEmptyPurse() { + const { chain } = this.state; + const { admin } = this.facets; + const orchAcct = await E(chain).makeAccount(); + return admin.makePurse(orchAcct); + }, + isLive(payment) { + return this.state.paymentLedger.has(payment); + }, + getAmountOf(payment) { + return this.state.paymentLedger.get(payment); + }, + burn(payment, optAmountShape = undefined) { + const { paymentLedger, paymentRecoveryFacets } = this.state; + + const amount = paymentLedger.get(payment); + if (optAmountShape !== undefined) { + mustMatch(amount, optAmountShape); + } + if (paymentRecoveryFacets.has(payment)) { + const paymentRecoveryFacet = paymentRecoveryFacets.get(payment); + paymentRecoveryFacet.unencumber(amount); + paymentRecoveryFacet.deletePayment(payment); + // TODO must all burn the same orch denom assets + // so the fullBalance goes down and the unencumbered balance + // eventually stays the same. + // Without this, the unencumbered balance will go up, which is + // completely broken. + } + }, + }, + mint: { + getIssuer() { + return this.facets.issuer; + }, + async mintPayment(newAmount) { + const { denom, optDenomMint } = this.state; + if (optDenomMint === undefined) { + throw Fail`Cannot mint without ${q(denom)} denom mint permission`; + } + const denomMint = optDenomMint; + const { issuer } = this.facets; + + await null; + if (this.state.optMintRecoveryPurse === undefined) { + this.state.optMintRecoveryPurse = await issuer.makeEmptyPurse(); + } + const mintRecoveryPurse = this.state.optMintRecoveryPurse; + const { recoveryFacet } = + /** @type {{ recoveryFacet: RecoveryFacet }} */ ( + purseKitAmp(mintRecoveryPurse) + ); + const orchAcct = recoveryFacet.getOrchAcct(); + const acctAddr = await E(orchAcct).getAddress(); + await E(denomMint).mintTo( + acctAddr, + harden({ + denom, + value: newAmount.value, + }), + ); + return mintRecoveryPurse.withdraw(newAmount); + }, + }, + admin: { + async makePurse(orchAcct) { + const { denom, paymentLedger, paymentRecoveryFacets } = this.state; + const { brand } = this.facets; + const { purse } = mockPurseKit( + orchAcct, + denom, + /** @type {Brand} */ (/** @type {unknown} */ (brand)), + paymentLedger, + paymentRecoveryFacets, + ); + return /** @type {MockPurse} */ (/** @type {unknown} */ (purse)); + }, + }, + }, + ); + + const mockERTPOrchestrator = zone.exoClass( + 'ERTPTools', + undefined, // TODO ERTPOrchestratorI, + /** + * @param {MockOrchestrator} orchestrator + * @param {MockOrchestratorAdmin} orchAdmin + */ + (orchestrator, orchAdmin) => { + const denom2IssuerKit = zone.mapStore( + 'denom2IssuerKit', + Denom2IssuerKitEntryShape, + ); + const brand2Denom = zone.weakMapStore( + 'brand2Denom', + Brand2DenomEntryShape, + ); + + return { + orchestrator, + orchAdmin, + denom2IssuerKit, + brand2Denom, + }; + }, + { + getDenomForBrand(brand) { + const { brand2Denom } = this.state; + return brand2Denom.get(brand); + }, + async provideIssuerKitForDenom( + denom, + defaultChainName = 'defaultChainName', + ) { + const { orchestrator, orchAdmin, denom2IssuerKit, brand2Denom } = + this.state; + await null; + if (!denom2IssuerKit.has(denom)) { + let chain; + let issuerKitPlus; + try { + // TODO should be a better way than try/catch to determine + // if a denom already exists + ({ chain } = await E(orchestrator).getDenomInfo(denom)); + issuerKitPlus = mockIssuerKit(denom, chain); + } catch (err) { + chain = await E(orchestrator).getChain(defaultChainName); + const denomMint = await E(orchAdmin).makeDenom(denom, chain); + issuerKitPlus = mockIssuerKit(denom, chain, denomMint); + } + denom2IssuerKit.init(denom, issuerKitPlus); + brand2Denom.init(issuerKitPlus.brand, denom); + } + return denom2IssuerKit.get(denom); + }, + }, + ); + + return mockERTPOrchestrator; +}; +harden(prepareERTPOrchestrator); diff --git a/packages/orchestration/src/mock-orch-ertp/mock-ertp/typeGuards.js b/packages/orchestration/src/mock-orch-ertp/mock-ertp/typeGuards.js new file mode 100644 index 00000000000..2a1cb334b09 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-ertp/typeGuards.js @@ -0,0 +1,80 @@ +// @jessie-check + +import { M, getInterfaceGuardPayload } from '@endo/patterns'; + +import { + AmountPatternShape, + AmountShape, + makeIssuerInterfaces, + PaymentShape, +} from '@agoric/ertp'; + +export const MockPurseShape = M.remotable('MockPurse'); +export const MockIssuerShape = M.remotable('MockIssuer'); +export const MockMintShape = M.remotable('MockMint'); + +/** + * @param {Pattern} [brandShape] + * @param {Pattern} [assetKindShape] + * @param {Pattern} [amountShape] + */ +export const mockIssuerInterfaces = ( + brandShape = undefined, + assetKindShape = undefined, + amountShape = AmountShape, +) => { + const { + IssuerI: OriginalIssuerI, + MintI: OriginalMintI, + PaymentI, + PurseIKit: { purse: OriginalPurseI }, + } = makeIssuerInterfaces(brandShape, assetKindShape, amountShape); + + const { methodGuards: originalPurseMethodGuards } = + getInterfaceGuardPayload(OriginalPurseI); + + const MockPurseI = M.interface('MockPurse', { + ...originalPurseMethodGuards, + getCurrentFullBalance: M.call().returns(M.eref(amountShape)), + getCurrentEncumberedBalance: M.call().returns(amountShape), + getCurrentUnencumberedBalance: M.call().returns(M.eref(amountShape)), + getCurrentAmount: M.call().returns(M.eref(amountShape)), + deposit: M.call(PaymentShape) + .optional(AmountPatternShape) + .returns(M.eref(amountShape)), + withdraw: M.call(amountShape).returns(M.eref(PaymentShape)), + }); + + const MockDepositFacetI = M.interface('MockDepositFacet', { + receive: getInterfaceGuardPayload(MockPurseI).methodGuards.deposit, + }); + + const MockPurseIKit = harden({ + purse: MockPurseI, + depositFacet: MockDepositFacetI, + }); + + const { methodGuards: originalIssuerMethodGuards } = + getInterfaceGuardPayload(OriginalIssuerI); + + const MockIssuerI = M.interface('MockIssuer', { + ...originalIssuerMethodGuards, + makeEmptyPurse: M.call().returns(M.eref(MockPurseShape)), + }); + + const { methodGuards: originalMintMethodGuards } = + getInterfaceGuardPayload(OriginalMintI); + + const MockMintI = M.interface('MockMint', { + ...originalMintMethodGuards, + mintPayment: M.call(amountShape).returns(M.eref(PaymentShape)), + }); + + return harden({ + IssuerI: MockIssuerI, + MintI: MockMintI, + PaymentI, + PurseIKit: MockPurseIKit, + }); +}; +harden(mockIssuerInterfaces); diff --git a/packages/orchestration/src/mock-orch-ertp/mock-ertp/types.ts b/packages/orchestration/src/mock-orch-ertp/mock-ertp/types.ts new file mode 100644 index 00000000000..d9c106fca88 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-ertp/types.ts @@ -0,0 +1,122 @@ +import type { ERef } from '@endo/eventual-send'; +import type { CopySet, Key, Pattern } from '@endo/patterns'; +import type { LatestTopic } from '@agoric/notifier'; + +import type { + Amount, + Brand, + Payment, + DepositFacetReceive, + DepositFacet, + PurseMethods, +} from '@agoric/ertp'; + +/** @see {DepositFacetReceive} */ +export type MockDepositFacetReceive = ( + payment: Payment, + optAmountShape?: Pattern, +) => ERef; + +/** @see {DepositFacet} */ +export type MockDepositFacet = { + /** + * 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: MockDepositFacetReceive; +}; + +/** + * 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(). + * + * @see {PurseMethods} + */ +export type MockPurse = { + /** + * Get the alleged Brand for this + * Purse + */ + getAllegedBrand: () => Brand; + + /** + * Get the amount contained in + * this purse + all payments still in the recoverySet at this moment + */ + getCurrentFullBalance: () => ERef; + + /** + * Get the amount contained all payments still in the recoverySet at this moment + */ + getCurrentEncumberedBalance: () => Amount; + + /** + * Get the amount contained in + * this purse. + */ + getCurrentUnencumberedBalance: () => ERef; + + /** + * Get the amount contained in + * this purse. + */ + getCurrentAmount: () => 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: () => MockDepositFacet; + + /** + * 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/orchestration/src/mock-orch-ertp/mock-orch/internal-typeGuards.js b/packages/orchestration/src/mock-orch-ertp/mock-orch/internal-typeGuards.js new file mode 100644 index 00000000000..cb06f677816 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-orch/internal-typeGuards.js @@ -0,0 +1,55 @@ +import { M } from '@endo/patterns'; +import { DenomAmountShape, DenomShape } from '../../typeGuards.js'; +import { + AcctAddrValueShape, + ChainNameShape, + MockChainAcctAddrShape, + MockChainShape, + MockDenomInfoShape, + MockOrchAccountShape, +} from './typeGuards.js'; + +export const MockDenomMintShape = M.remotable('MockDenomMint'); +export const MockOrchAdminShape = M.remotable('MockOrchAdmin'); +export const BalancesMapShape = M.remotable('balancesMap'); +export const AccountsMapShape = M.remotable('accountsMap'); +export const ChainsMapShape = M.remotable('chainsMap'); +export const DenomsMapShape = M.remotable('denomsMap'); + +export const MockDenomMintI = M.interface('MockDenomMint', { + mintTo: M.call(MockChainAcctAddrShape, DenomAmountShape).returns( + M.eref(M.undefined()), + ), +}); + +export const MockOrchAdminI = M.interface('MockOrchAdmin', { + makeDenom: M.call(DenomShape, M.eref(MockChainShape)).returns( + M.eref(MockDenomMintShape), + ), +}); + +export const BalancesEntryShape = harden({ + keyShape: DenomShape, + valueShape: DenomAmountShape, +}); + +export const AccountsEntryShape = { + keyShape: AcctAddrValueShape, + valueShape: { + account: MockOrchAccountShape, + balances: BalancesMapShape, + }, +}; + +export const ChainsEntryShape = { + keyShape: ChainNameShape, + valueShape: { + chain: MockChainShape, + accounts: AccountsMapShape, + }, +}; + +export const DenomsEntryShape = { + keyShape: DenomShape, + valueShape: MockDenomInfoShape, +}; diff --git a/packages/orchestration/src/mock-orch-ertp/mock-orch/internal-types.ts b/packages/orchestration/src/mock-orch-ertp/mock-orch/internal-types.ts new file mode 100644 index 00000000000..66333894845 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-orch/internal-types.ts @@ -0,0 +1,49 @@ +import type { ERef } from '@endo/far'; +import type { MapStore } from '@agoric/store'; +import type { Denom, DenomAmount } from '../../orchestration-api.ts'; +import type { + MockChain, + MockDenomInfo, + MockChainAcctAddr, + AcctAddrValue, + MockOrchAccount, + ChainName, + MockOrchestrator, +} from './types.ts'; + +export type MockDenomMint = { + // like transfer, but with no src account + mintTo(destAddr: MockChainAcctAddr, denomAmount: DenomAmount): ERef; +}; + +/** + * Essentially, minting authority for mock testing purposes. + */ +export type MockOrchestratorAdmin = { + makeDenom(denom: Denom, chainP: ERef): ERef; +}; + +export type BalancesMap = MapStore; + +export type AccountsMap = MapStore< + AcctAddrValue, + { + account: MockOrchAccount; + balances: BalancesMap; + } +>; + +export type ChainsMap = MapStore< + ChainName, + { + chain: MockChain; + accounts: AccountsMap; + } +>; + +export type DenomsMap = MapStore; + +export type MockOrchestratorKit = { + orchestrator: MockOrchestrator; + admin: MockOrchestratorAdmin; +}; diff --git a/packages/orchestration/src/mock-orch-ertp/mock-orch/mock-orch.js b/packages/orchestration/src/mock-orch-ertp/mock-orch/mock-orch.js new file mode 100644 index 00000000000..0a5ddf47216 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-orch/mock-orch.js @@ -0,0 +1,243 @@ +import { Fail, q } from '@endo/errors'; +import { provideLazy } from '@agoric/store'; + +import { + MockChainI, + MockOrchAccountI, + MockOrchestratorI, +} from './typeGuards.js'; +import { + AccountsEntryShape, + BalancesEntryShape, + ChainsEntryShape, + DenomsEntryShape, + MockDenomMintI, + MockOrchAdminI, +} from './internal-typeGuards.js'; + +// TODO declare less. infer more. + +/** + * @import {Zone} from '@agoric/base-zone' + * + * @import {Denom} from '../../orchestration-api.js' + * @import {AcctAddrValue, ChainName, MockChain, MockOrchAccount} from './types.js' + * @import {AccountsMap, ChainsMap, DenomsMap, MockOrchestratorKit} from './internal-types.js'; + */ + +const decrBalance = (myBalances, denom, deltaValue) => { + const { value: myOldBalanceValue } = myBalances.get(denom); + // Would be really good to do this source check synchronously + // and 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 and in order. + // TODO is this realistic for the real orch? + myBalances.set(denom, harden({ denom, value: myNewBalanceValue })); +}; + +const getDestBalances = (chains, destAddr) => { + const { chainId: destChainId, value: destAcctAddrValue } = destAddr; + const { accounts: destChainAccounts } = chains.get(destChainId); + const { balances: dBalances } = destChainAccounts.get(destAcctAddrValue); + return dBalances; +}; + +const restoreBalance = (myBalances, denom, deltaValue) => { + const { value: myNextOldBalanceValue } = myBalances.get(denom); + const myNextNewBalanceValue = myNextOldBalanceValue + deltaValue; + myBalances.set(denom, harden({ denom, value: myNextNewBalanceValue })); +}; + +const incrBalance = (destBalances, denom, deltaValue) => { + 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 })); + } +}; + +/** + * @param {Zone} zone + */ +export const prepareMockOrchestratorKit = zone => { + const makeMapStore = zone.detached().mapStore; + + /** + * @type {( + * chains: ChainsMap, + * accounts: AccountsMap, + * chainId: ChainName, + * chain: MockChain, + * ) => MockOrchAccount} + */ + const mockOrchAccount = zone.exoClass( + 'MockOrchAccount', + MockOrchAccountI, + /** + * @param {ChainsMap} chains + * @param {AccountsMap} accounts + * @param {ChainName} chainId + * @param {MockChain} chain + */ + (chains, accounts, chainId, chain) => { + /** @type {AcctAddrValue} */ + const acctAddrValue = `${accounts.getSize()}`; + return { chains, accounts, chainId, chain, acctAddrValue }; + }, + { + getAddress() { + const { chainId, acctAddrValue } = this.state; + 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.has(denom) + ? balances.get(denom) + : harden({ + denom, + value: 0n, + }); + }, + async transfer(destAddr, denomAmount) { + const { chains, accounts, acctAddrValue } = this.state; + const { balances: myBalances } = accounts.get(acctAddrValue); + const { denom, value: deltaValue } = denomAmount; + + decrBalance(myBalances, denom, deltaValue); + + await null; // sometime later... + + let destBalances; + try { + destBalances = getDestBalances(chains, destAddr); + } catch (err) { + // A failure at this point means the `denomAmount` is not yet spent + // at dest, and so should be restored to `myBalances`. + restoreBalance(myBalances, denom, deltaValue); + throw err; + } + incrBalance(destBalances, denom, deltaValue); + }, + }, + ); + + const makeMockChain = zone.exoClass( + 'MockChain', + MockChainI, + /** + * @param {ChainsMap} chains + * @param {DenomsMap} denoms + * @param {ChainName} chainName + */ + (chains, denoms, chainName) => ({ + chains, + denoms, + chainId: chainName, + }), + { + getChainInfo() { + const { chainId } = this.state; + return harden({ chainId }); + }, + async makeAccount() { + const { self } = this; + const { chains, chainId } = this.state; + const { accounts } = chains.get(chainId); + + const account = mockOrchAccount(chains, accounts, chainId, self); + const { value: acctAddrValue } = account.getAddress(); + accounts.init( + acctAddrValue, + harden({ + account, + balances: makeMapStore('balances', BalancesEntryShape), + }), + ); + return account; + }, + }, + ); + + const mockDenomMint = zone.exoClass( + 'MockDenomMint', + MockDenomMintI, + /** + * @param {ChainsMap} chains + * @param {DenomsMap} denoms + * @param {Denom} denom + */ + (chains, denoms, denom) => ({ + chains, + denoms, + denom, + }), + { + mintTo(destAddr, denomAmount) { + const { chains, denom } = this.state; + const { denom: sameDenom, value: deltaValue } = denomAmount; + denom === sameDenom || + Fail`${q(denom)} should be same as ${q(sameDenom)}`; + + const destBalances = getDestBalances(chains, destAddr); + incrBalance(destBalances, denom, deltaValue); + }, + }, + ); + + /** @type {() => MockOrchestratorKit} */ + const mockOrchestratorKit = zone.exoClassKit( + 'MockOrchestrator', + { + orchestrator: MockOrchestratorI, + admin: MockOrchAdminI, + }, + () => { + /** @type {ChainsMap} */ + const chains = makeMapStore('chains', ChainsEntryShape); + /** @type {DenomsMap} */ + const denoms = makeMapStore('denoms', DenomsEntryShape); + return { chains, denoms }; + }, + { + orchestrator: { + getChain(chainName) { + const { chains, denoms } = this.state; + // @ts-expect-error typing problems with provideLazy + const { chain } = provideLazy(chains, chainName, cName => + harden({ + chain: makeMockChain(chains, denoms, cName), + accounts: makeMapStore('accounts', AccountsEntryShape), + }), + ); + return chain; + }, + getDenomInfo(denom) { + return this.state.denoms.get(denom); + }, + }, + admin: { + async makeDenom(denom, chainP) { + const chain = await chainP; + const { chains, denoms } = this.state; + // init will throw if `denom` name is already taken + denoms.init(denom, harden({ chain })); + return mockDenomMint(chains, denoms, denom); + }, + }, + }, + ); + return mockOrchestratorKit; +}; +harden(prepareMockOrchestratorKit); diff --git a/packages/orchestration/src/mock-orch-ertp/mock-orch/typeGuards.js b/packages/orchestration/src/mock-orch-ertp/mock-orch/typeGuards.js new file mode 100644 index 00000000000..524b76bfb61 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-orch/typeGuards.js @@ -0,0 +1,65 @@ +// @jessie-check +import { M } from '@endo/patterns'; +import { + ChainInfoShape, + DenomAmountShape, + DenomShape, +} from '../../typeGuards.js'; + +export const MockOrchAccountShape = M.remotable('MockOrchAccount'); +export const MockChainShape = M.remotable('MockChain'); +export const MockOrchestratorShape = M.remotable('MockOrchestrator'); + +// copied from portfolio-holder-kit.js +export const ChainNameShape = M.string(); + +export const AcctAddrValueShape = M.string(); + +/** + * @see {ChainAddressShape} + * @see {MockChainAcctAddr} + */ +export const MockChainAcctAddrShape = harden({ + chainId: ChainNameShape, + value: AcctAddrValueShape, // acctAddrValue +}); + +/** + * @see {orchestrationAccountMethods} + * @see {MockOrchAccount} + */ +export const MockOrchAccountI = M.interface('MockOrchAccount', { + getAddress: M.call().returns(MockChainAcctAddrShape), + getBalances: M.call().returns(M.eref(M.arrayOf(DenomAmountShape))), + getBalance: M.call(DenomShape).returns(M.eref(DenomAmountShape)), + transfer: M.call(MockChainAcctAddrShape, DenomAmountShape).returns( + M.eref(M.undefined()), + ), +}); + +/** + * @see {DenomInfoShape} + * @see {MockDenomInfo} + */ +export const MockDenomInfoShape = harden({ + chain: MockChainShape, +}); + +/** + * @see {chainFacadeMethods} + * @see {MockChain} + * @see {OrchestratorI} + */ +export const MockChainI = M.interface('MockChain', { + getChainInfo: M.call().returns(M.eref(ChainInfoShape)), + makeAccount: M.call().returns(M.eref(MockOrchAccountShape)), +}); + +/** + * @see {OrchestratorI} + * @see {MockOrchestrator} + */ +export const MockOrchestratorI = M.interface('MockOrchestrator', { + getChain: M.call(ChainNameShape).returns(M.eref(MockChainShape)), + getDenomInfo: M.call(DenomShape).returns(MockDenomInfoShape), +}); diff --git a/packages/orchestration/src/mock-orch-ertp/mock-orch/types.ts b/packages/orchestration/src/mock-orch-ertp/mock-orch/types.ts new file mode 100644 index 00000000000..84fc8128816 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/mock-orch/types.ts @@ -0,0 +1,69 @@ +import type { ERef } from '@endo/eventual-send'; + +import type { + Denom, + DenomAmount, + ChainAddress, + OrchestrationAccount, + ChainInfo, + DenomInfo, + Chain, + Orchestrator, +} from '@agoric/orchestration'; + +export type ChainName = string; +export type AcctAddrValue = string; + +/** + * @see {ChainAddress}, which actually names an account on a chain. + * Like a widely-held deposit-facet. + */ +export type MockChainAcctAddr = { + chainId: ChainName; + value: AcctAddrValue; // acctAddrValue +}; + +/** + * @see {OrchestrationAccount} + */ +export type MockOrchAccount = { + getAddress(): MockChainAcctAddr; + getBalances(): ERef; + getBalance(denom: Denom): ERef; + transfer(destAddr: MockChainAcctAddr, denomAmount: DenomAmount): ERef; +}; + +/** + * @see {ChainInfo}, which is in the intersection of the existing + * `ChainInfo` possibilities. + */ +export type MockChainInfo = { + chainId: ChainName; +}; + +/** + * @see {DenomInfo} + */ +export type MockDenomInfo = { + // Omitting "brand" from DenomInfo in order to get clearer layering + // brand: Brand; + chain: MockChain; +}; + +/** + * @see {Chain} + */ +export type MockChain = { + getChainInfo(): ERef; + makeAccount(): ERef; +}; + +/** + * @see {Orchestrator} + */ +export type MockOrchestrator = { + getChain(chainName: ChainName): ERef; + getDenomInfo(denom: Denom): MockDenomInfo; + // Omitting "asAmount" from Orchestrator in order to get clearer layering + // asAmount(denomAmount: DenomAmount): Amount; +}; diff --git a/packages/orchestration/src/mock-orch-ertp/notes.txt b/packages/orchestration/src/mock-orch-ertp/notes.txt new file mode 100644 index 00000000000..539b7a9e584 --- /dev/null +++ b/packages/orchestration/src/mock-orch-ertp/notes.txt @@ -0,0 +1,43 @@ +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 + +mintTo(d, a), like succeed case of transfer + dub += a + deb unchanged + dfb += a + +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/orchestration/test/facade.test.ts b/packages/orchestration/test/facade.test.ts index 728e38016d5..db0512e0d2a 100644 --- a/packages/orchestration/test/facade.test.ts +++ b/packages/orchestration/test/facade.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @jessie.js/safe-await-separator */ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { VowTools } from '@agoric/vow'; diff --git a/packages/orchestration/test/mock-orch-ertp/mock-ertp/mock-ertp.test.js b/packages/orchestration/test/mock-orch-ertp/mock-ertp/mock-ertp.test.js new file mode 100644 index 00000000000..72e7dc06d90 --- /dev/null +++ b/packages/orchestration/test/mock-orch-ertp/mock-ertp/mock-ertp.test.js @@ -0,0 +1,49 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { prepareMockOrchestratorKit } from '../../../src/mock-orch-ertp/mock-orch/mock-orch.js'; +import { prepareERTPOrchestrator } from '../../../src/mock-orch-ertp/mock-ertp/mock-ertp.js'; + +/** + * @import {ERef} from '@endo/eventual-send' + * + * @import {Amount} from '@agoric/ertp' + */ + +test('mock ertp', async t => { + const baggage = makeScalarBigMapStore('test-baggage', { durable: true }); + const zone = makeDurableZone(baggage); + const mockOrchestratorKit = prepareMockOrchestratorKit(zone); + const mockERTPOrchestrator = prepareERTPOrchestrator(zone); + const { orchestrator, admin } = mockOrchestratorKit(); + const ertpOrchestrator = mockERTPOrchestrator(orchestrator, admin); + + const aChain = await orchestrator.getChain('a'); + t.truthy(aChain); + const { + brand: bucksBrand, + issuer: bucksIssuer, + mint: bucksMint, + admin: _bucksAdmin, + } = await ertpOrchestrator.provideIssuerKitForDenom('bucks', 'a'); + + const bucks = value => + harden({ + brand: bucksBrand, + value, + }); + + const aliceBucksPurse = await bucksIssuer.makeEmptyPurse(); + const bobBucksPurse = await bucksIssuer.makeEmptyPurse(); + + const initBucksPayment = await bucksMint.mintPayment(bucks(300n)); + + t.deepEqual(await aliceBucksPurse.getCurrentAmount(), bucks(0n)); + t.deepEqual(await bobBucksPurse.getCurrentAmount(), bucks(0n)); + t.deepEqual(await bucksIssuer.getAmountOf(initBucksPayment), bucks(300n)); + + t.deepEqual(await aliceBucksPurse.deposit(initBucksPayment), bucks(300n)); + t.deepEqual(await aliceBucksPurse.getCurrentAmount(), bucks(300n)); + t.false(await bucksIssuer.isLive(initBucksPayment)); +}); diff --git a/packages/orchestration/test/mock-orch-ertp/mock-orch/mock-orch.test.js b/packages/orchestration/test/mock-orch-ertp/mock-orch/mock-orch.test.js new file mode 100644 index 00000000000..90d2511d747 --- /dev/null +++ b/packages/orchestration/test/mock-orch-ertp/mock-orch/mock-orch.test.js @@ -0,0 +1,53 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { prepareMockOrchestratorKit } from '../../../src/mock-orch-ertp/mock-orch/mock-orch.js'; + +test('mock orch chain', async t => { + const baggage = makeScalarBigMapStore('test-baggage', { durable: true }); + const zone = makeDurableZone(baggage); + const makeMockOrchestratorKit = prepareMockOrchestratorKit(zone); + const { orchestrator, admin } = makeMockOrchestratorKit(); + + const aChain = await orchestrator.getChain('a'); + const { chainId: aChainId } = await aChain.getChainInfo(); + t.is(aChainId, 'a'); + const aliceAcct = await aChain.makeAccount(); + const { chainId: aliceChainId, value: aliceAcctAddrValue } = + aliceAcct.getAddress(); + t.is(aliceChainId, 'a'); + t.is(aliceAcctAddrValue, '0'); + const aliceBalances = await aliceAcct.getBalances(); + t.is(aliceBalances.length, 0); + + const bChain = await orchestrator.getChain('b'); + const { chainId: bChainId } = await bChain.getChainInfo(); + t.is(bChainId, 'b'); + const bobAcct = await bChain.makeAccount(); + const { chainId: bobChainId, value: bobAcctAddrValue } = bobAcct.getAddress(); + t.is(bobChainId, 'b'); + t.is(bobAcctAddrValue, '0'); + const bobBalances = await bobAcct.getBalances(); + t.is(bobBalances.length, 0); + + const bucksDenomMint = await admin.makeDenom('bucks', aChain); + await bucksDenomMint.mintTo( + aliceAcct.getAddress(), + harden({ + denom: 'bucks', + value: 300n, + }), + ); + t.deepEqual(await aliceAcct.getBalances(), [{ denom: 'bucks', value: 300n }]); + + await aliceAcct.transfer( + bobAcct.getAddress(), + harden({ + denom: 'bucks', + value: 100n, + }), + ); + t.deepEqual(await aliceAcct.getBalances(), [{ denom: 'bucks', value: 200n }]); + t.deepEqual(await bobAcct.getBalances(), [{ denom: 'bucks', value: 100n }]); +});