UNPKG

@agoric/zoe

Version:

Zoe: the Smart Contract Framework for Offer Enforcement

375 lines (339 loc) • 12.7 kB
import { Fail } from '@endo/errors'; import { E } from '@endo/eventual-send'; import { makePromiseKit } from '@endo/promise-kit'; import { mustMatch, keyEQ } from '@agoric/store'; import { AssetKind } from '@agoric/ertp'; import { fromUniqueEntries } from '@agoric/internal'; import { satisfiesWant } from '../contractFacet/offerSafety.js'; import { atomicTransfer, fromOnly, toOnly } from './atomicTransfer.js'; /** * @import {Pattern} from '@endo/patterns'; * @import {ContractMeta, Invitation, Proposal, ZCF, ZCFSeat} from '@agoric/zoe'; */ export const defaultAcceptanceMsg = `The offer has been accepted. Once the contract has been completed, please check your payout`; const getKeysSorted = obj => harden(Reflect.ownKeys(obj || {}).sort()); export const assertIssuerKeywords = (zcf, expected) => { const { issuers } = zcf.getTerms(); const actual = getKeysSorted(issuers); expected = [...expected]; // in case hardened expected.sort(); keyEQ(actual, harden(expected)) || Fail`keywords: ${actual} were not as expected: ${expected}`; }; /** * @typedef {object} ZcfSeatPartial * @property {() => ProposalRecord} getProposal * @property {() => Allocation} getCurrentAllocation */ /** * Check whether an update to currentAllocation satisfies * proposal.want. Note that this is half of the offer safety * check; whether the allocation constitutes a refund is not * checked. The update is merged with currentAllocation * (update's values prevailing if the keywords are the same) * to produce the newAllocation. The return value is 0 for * false and 1 for true. When multiples are introduced, any * positive return value will mean true. * * @param {any} _ignored no longer used. * @param {ZcfSeatPartial} seat * @param {AmountKeywordRecord} update * @returns {0|1} */ export const satisfies = (_ignored, seat, update) => { const currentAllocation = seat.getCurrentAllocation(); const newAllocation = { ...currentAllocation, ...update }; const proposal = seat.getProposal(); return satisfiesWant(proposal, newAllocation); }; /** @type {Swap} */ export const swap = (zcf, leftSeat, rightSeat) => { try { zcf.atomicRearrange( harden([ [rightSeat, leftSeat, leftSeat.getProposal().want], [leftSeat, rightSeat, rightSeat.getProposal().want], ]), ); } catch (err) { leftSeat.fail(err); rightSeat.fail(err); throw err; } leftSeat.exit(); rightSeat.exit(); return defaultAcceptanceMsg; }; /** @type {SwapExact} */ export const swapExact = (zcf, leftSeat, rightSeat) => { try { zcf.atomicRearrange( harden([ fromOnly(rightSeat, rightSeat.getProposal().give), fromOnly(leftSeat, leftSeat.getProposal().give), toOnly(leftSeat, leftSeat.getProposal().want), toOnly(rightSeat, rightSeat.getProposal().want), ]), ); } catch (err) { leftSeat.fail(err); rightSeat.fail(err); throw err; } leftSeat.exit(); rightSeat.exit(); return defaultAcceptanceMsg; }; /** * @typedef ExpectedRecord * @property {Record<Keyword, null>} [want] * @property {Record<Keyword, null>} [give] * @property {Partial<Record<keyof ProposalRecord['exit'], null>>} [exit] */ /** * Check the seat's proposal against `proposalShape`. * If the client submits an offer which does not match * these expectations, the seat will be exited (and payments refunded). * * @param {ZCFSeat} seat * @param {Pattern} proposalShape */ export const fitProposalShape = (seat, proposalShape) => // TODO remove this harden, obligating our caller to harden. mustMatch(seat.getProposal(), harden(proposalShape), 'proposal'); /** * Check the seat's proposal against an `expected` record that says * what "shape" of proposal is acceptable. * * Note that by our current terminology, this function is misnamed because * we use * ["Shape" to refer to patterns](https://github.com/Agoric/agoric-sdk/blob/master/packages/store/src/types.js#L56-L74), * and the `expected` argument is not such a pattern. Rather it is an ad-hoc * pattern-like special case record that is different and much less expressive. * * This ExpectedRecord is like a Proposal, but the amounts in 'want' * and 'give' should be null; the exit clause should specify a rule with * null contents. If the client submits an offer which does not match * these expectations, the seat will be exited (and payments refunded). * * @deprecated Use optional `proposalShape` argument to `makeInvitation` with * a genuine pattern. * @param {ZCFSeat} seat * @param {ExpectedRecord} expected */ export const assertProposalShape = (seat, expected) => { assert.typeof(expected, 'object'); !Array.isArray(expected) || Fail`Expected must be an non-array object`; const assertValuesNull = e => { if (e !== undefined) { for (const value of Object.values(e)) { value === null || Fail`The value of the expected record must be null but was ${value}`; } } }; // Assert values of the expected record are all null. We do not // check the values of the actual proposal. assertValuesNull(expected.give); assertValuesNull(expected.want); assertValuesNull(expected.exit); const actual = seat.getProposal(); const assertKeys = (a, e) => { if (e !== undefined) { keyEQ(getKeysSorted(a), getKeysSorted(e)) || Fail`actual ${a} did not match expected ${e}`; } }; assertKeys(actual.give, expected.give); assertKeys(actual.want, expected.want); assertKeys(actual.exit, expected.exit); }; /* Given a brand, assert that brand is AssetKind.NAT. */ export const assertNatAssetKind = (zcf, brand) => { zcf.getAssetKind(brand) === AssetKind.NAT || Fail`brand must be AssetKind.NAT`; }; export const depositToSeatSuccessMsg = `Deposit and reallocation successful.`; /** * Deposit payments such that their amounts are reallocated to a seat. * The `amounts` and `payments` records must have corresponding * keywords. * * @param {ZCF} zcf * @param {ZCFSeat} recipientSeat * @param {AmountKeywordRecord} amounts * @param {PaymentPKeywordRecord} payments * @returns {Promise<string>} `Deposit and reallocation successful.` */ export const depositToSeat = async (zcf, recipientSeat, amounts, payments) => { !recipientSeat.hasExited() || Fail`The recipientSeat cannot have exited.`; // We will create a temporary offer to be able to escrow our payments // with Zoe. const reallocateAfterDeposit = tempSeat => { // After the assets are deposited, reallocate them onto the recipient seat and // exit the temporary seat. Note that the offerResult is the return value of this // function, so this synchronous trade must happen before the // offerResult resolves. atomicTransfer(zcf, tempSeat, recipientSeat, amounts); tempSeat.exit(); return depositToSeatSuccessMsg; }; const invitation = zcf.makeInvitation( reallocateAfterDeposit, 'temporary seat for deposit', ); const proposal = harden({ give: amounts }); harden(payments); // To escrow the payment, we must get the Zoe Service facet and // make an offer const zoe = zcf.getZoeService(); const tempUserSeat = E(zoe).offer(invitation, proposal, payments); // This will be a promise for the string: `Deposit and reallocation // successful.` It will only fulfill after the assets have been // successfully reallocated to the recipient seat. return E(tempUserSeat).getOfferResult(); }; /** * Withdraw payments from a seat. Note that withdrawing the amounts of * the payments must not and cannot violate offer safety for the seat. The * `amounts` and `payments` records must have corresponding keywords. * * @param {ZCF} zcf * @param {ZCFSeat} seat * @param {AmountKeywordRecord} amounts * @returns {Promise<PaymentPKeywordRecord>} */ export const withdrawFromSeat = async (zcf, seat, amounts) => { !seat.hasExited() || Fail`The seat cannot have exited.`; const { zcfSeat: tempSeat, userSeat: tempUserSeatP } = zcf.makeEmptySeatKit(); atomicTransfer(zcf, seat, tempSeat, amounts); tempSeat.exit(); return E(tempUserSeatP).getPayouts(); }; /** * Save all of the issuers in an issuersKeywordRecord to ZCF, using * the method `zcf.saveIssuer`. This does not error if any of the keywords * already exist. If the keyword is already present, it is ignored. * * @param {ZCF} zcf * @param {IssuerKeywordRecord} issuerKeywordRecord Issuers to save to * ZCF */ export const saveAllIssuers = async (zcf, issuerKeywordRecord = harden({})) => { const { issuers } = zcf.getTerms(); const issuersPSaved = Object.entries(issuerKeywordRecord).map( ([keyword, issuer]) => { // If the keyword does not yet exist, add it and the // associated issuer. if (issuers[keyword] === undefined) { return zcf.saveIssuer(issuer, keyword); } return undefined; }, ); return Promise.all(issuersPSaved); }; /** @type {MapKeywords} */ export const mapKeywords = (keywordRecord = {}, keywordMapping) => { return harden( fromUniqueEntries( Object.entries(keywordRecord).map(([keyword, value]) => { if (keywordMapping[keyword] === undefined) { return [keyword, value]; } return [keywordMapping[keyword], value]; }), ), ); }; /** @type {Reverse} */ const reverse = (keywordRecord = {}) => { return harden( fromUniqueEntries( Object.entries(keywordRecord).map(([key, value]) => [value, key]), ), ); }; /** * Make an offer to another contract instance (labeled contractB below), * withdrawing the payments for the offer from a seat in the current * contract instance (contractA) and depositing the payouts in another * seat in the current contract instance (contractA). * * @param {ZCF} zcf * Zoe Contract Facet for contractA * * @param {ERef<Invitation<Result, Args>>} invitation * Invitation to contractB * * @param {KeywordKeywordRecord | undefined} keywordMapping * Mapping of keywords used in contractA to keywords to be used in * contractB. Note that the pathway to deposit the payout back to * contractA reverses this mapping. * * @param {Proposal} proposal * The proposal for the offer to be made to contractB * * @param {ZCFSeat} fromSeat * The seat in contractA to take the offer payments from. * * @param {ZCFSeat} [toSeat] * The seat in contractA to deposit the payout of the offer to. * If `toSeat` is not provided, this defaults to the `fromSeat`. * * @param {Args} [offerArgs] * Additional contract-specific optional arguments in a record. * * @returns {Promise<{userSeatPromise: Promise<UserSeat<Result>>, deposited: Promise<AmountKeywordRecord>}>} * A promise for the userSeat for the offer to the other contract, and a * promise (`deposited`) which resolves when the payout for the offer has been * deposited to the `toSeat`. * Any failures of the invitation will be returned by `userSeatPromise.getOfferResult()`. * * @template {object} Args Offer args * @template {object} Result Offer result */ export const offerTo = async ( zcf, invitation, keywordMapping, proposal, fromSeat, toSeat, offerArgs, ) => { if (keywordMapping === undefined) { keywordMapping = harden({}); } const definedToSeat = toSeat !== undefined ? toSeat : fromSeat; const zoe = zcf.getZoeService(); const mappingReversed = reverse(keywordMapping); const newKeywords = proposal !== undefined ? mapKeywords(proposal.give, mappingReversed) : harden({}); // the proposal is in the other contract's keywords, but we want to // use `proposal.give` to withdraw const payments = await withdrawFromSeat(zcf, fromSeat, newKeywords); // Map to the other contract's keywords const paymentsForOtherContract = mapKeywords(payments, keywordMapping); const userSeatPromise = E(zoe).offer( invitation, proposal, paymentsForOtherContract, offerArgs, ); const depositedPromiseKit = makePromiseKit(); const doDeposit = async payoutPayments => { // after getPayouts(), getFinalAllocation() resolves promptly. const amounts = await E(userSeatPromise).getFinalAllocation(); // Map back to the original contract's keywords const mappedAmounts = mapKeywords(amounts, mappingReversed); const mappedPayments = mapKeywords(payoutPayments, mappingReversed); await depositToSeat(zcf, definedToSeat, mappedAmounts, mappedPayments); depositedPromiseKit.resolve(mappedAmounts); }; void E(userSeatPromise).getPayouts().then(doDeposit); // TODO rename return key; userSeatPromise is a remote UserSeat return harden({ userSeatPromise, deposited: depositedPromiseKit.promise }); };