-
Notifications
You must be signed in to change notification settings - Fork 215
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(fast-usdc): settler disburses or forwards funds
- Loading branch information
Showing
3 changed files
with
537 additions
and
104 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,97 +1,291 @@ | ||
import { assertAllDefined } from '@agoric/internal'; | ||
import { AmountMath } from '@agoric/ertp'; | ||
import { assertAllDefined, makeTracer } from '@agoric/internal'; | ||
import { atob } from '@endo/base64'; | ||
import { makeError, q } from '@endo/errors'; | ||
import { E } from '@endo/far'; | ||
import { M } from '@endo/patterns'; | ||
|
||
import { PendingTxStatus } from '../constants.js'; | ||
import { addressTools } from '../utils/address.js'; | ||
import { makeFeeTools } from '../utils/fees.js'; | ||
import { EvmHashShape } from '../type-guards.js'; | ||
|
||
/** | ||
* @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; | ||
* @import {Denom} from '@agoric/orchestration'; | ||
* @import {Denom, OrchestrationAccount, ChainHub} from '@agoric/orchestration'; | ||
* @import {WithdrawToSeat} from '@agoric/orchestration/src/utils/zoe-tools' | ||
* @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats'; | ||
* @import {Zone} from '@agoric/zone'; | ||
* @import {NobleAddress} from '../types.js'; | ||
* @import {HostOf, HostInterface} from '@agoric/async-flow'; | ||
* @import {TargetRegistration} from '@agoric/vats/src/bridge-target.js'; | ||
* @import {NobleAddress, LiquidityPoolKit, FeeConfig, EvmHash} from '../types.js'; | ||
* @import {StatusManager} from './status-manager.js'; | ||
*/ | ||
|
||
const trace = makeTracer('Settler'); | ||
|
||
/** | ||
* NOTE: not meant to be parsable. | ||
* | ||
* @param {NobleAddress} addr | ||
* @param {bigint} amount | ||
*/ | ||
const makeMintedEarlyKey = (addr, amount) => | ||
`pendingTx:${JSON.stringify([addr, String(amount)])}`; | ||
|
||
/** | ||
* @param {Zone} zone | ||
* @param {object} caps | ||
* @param {StatusManager} caps.statusManager | ||
* @param {Brand<'nat'>} caps.USDC | ||
* @param {Pick<ZCF, 'makeEmptySeatKit' | 'atomicRearrange'>} caps.zcf | ||
* @param {FeeConfig} caps.feeConfig | ||
* @param {HostOf<WithdrawToSeat>} caps.withdrawToSeat | ||
* @param {import('@agoric/vow').VowTools} caps.vowTools | ||
* @param {ChainHub} caps.chainHub | ||
*/ | ||
export const prepareSettler = (zone, { statusManager }) => { | ||
export const prepareSettler = ( | ||
zone, | ||
{ statusManager, USDC, zcf, feeConfig, withdrawToSeat, vowTools, chainHub }, | ||
) => { | ||
assertAllDefined({ statusManager }); | ||
return zone.exoClass( | ||
return zone.exoClassKit( | ||
'Fast USDC Settler', | ||
M.interface('SettlerI', { | ||
receiveUpcall: M.call(M.record()).returns(M.promise()), | ||
}), | ||
{ | ||
creator: M.interface('SettlerCreatorI', { | ||
monitorMintingDeposits: M.callWhen().returns(M.any()), | ||
}), | ||
tap: M.interface('SettlerTapI', { | ||
receiveUpcall: M.call(M.record()).returns(M.promise()), | ||
}), | ||
notify: M.interface('SettlerNotifyI', { | ||
notifyAdvancingResult: M.call( | ||
M.string(), | ||
M.nat(), | ||
M.boolean(), | ||
).returns(), | ||
}), | ||
self: M.interface('SettlerSelfI', { | ||
disburse: M.call(EvmHashShape, M.string(), M.nat()).returns( | ||
M.promise(), | ||
), | ||
forward: M.call( | ||
M.opt(EvmHashShape), | ||
M.string(), | ||
M.nat(), | ||
M.string(), | ||
).returns(), | ||
}), | ||
transferHandler: M.interface('SettlerTransferI', { | ||
onFulfilled: M.call(M.any(), M.record()).returns(), | ||
onRejected: M.call(M.any(), M.record()).returns(), | ||
}), | ||
}, | ||
/** | ||
* | ||
* @param {{ | ||
* sourceChannel: IBCChannelID; | ||
* remoteDenom: Denom | ||
* remoteDenom: Denom; | ||
* repayer: LiquidityPoolKit['repayer']; | ||
* settlementAccount: HostInterface<OrchestrationAccount<{ chainId: 'agoric' }>> | ||
* }} config | ||
*/ | ||
config => harden(config), | ||
config => { | ||
return { | ||
...config, | ||
/** @type {HostInterface<TargetRegistration>|undefined} */ | ||
registration: undefined, | ||
/** @type {SetStore<ReturnType<typeof makeMintedEarlyKey>>} */ | ||
mintedEarly: zone.detached().setStore('mintedEarly'), | ||
}; | ||
}, | ||
{ | ||
/** @param {VTransferIBCEvent} event */ | ||
async receiveUpcall(event) { | ||
if (event.packet.source_channel !== this.state.sourceChannel) { | ||
// TODO #10390 log all early returns | ||
// only interested in packets from the issuing chain | ||
return; | ||
} | ||
const tx = /** @type {FungibleTokenPacketData} */ ( | ||
JSON.parse(atob(event.packet.data)) | ||
); | ||
if (tx.denom !== this.state.remoteDenom) { | ||
// only interested in uusdc | ||
return; | ||
} | ||
|
||
if (!addressTools.hasQueryParams(tx.receiver)) { | ||
// only interested in receivers with query params | ||
return; | ||
} | ||
|
||
const { EUD } = addressTools.getQueryParams(tx.receiver); | ||
if (!EUD) { | ||
// only interested in receivers with EUD parameter | ||
return; | ||
} | ||
|
||
// TODO discern between SETTLED and OBSERVED; each has different fees/destinations | ||
const hasPendingSettlement = statusManager.hasPendingSettlement( | ||
creator: { | ||
async monitorMintingDeposits() { | ||
const { settlementAccount } = this.state; | ||
const registration = await vowTools.when( | ||
settlementAccount.monitorTransfers(this.facets.tap), | ||
); | ||
assert.typeof(registration, 'object'); | ||
this.state.registration = registration; | ||
}, | ||
}, | ||
tap: { | ||
/** @param {VTransferIBCEvent} event */ | ||
async receiveUpcall(event) { | ||
const { sourceChannel, remoteDenom } = this.state; | ||
const { packet } = event; | ||
if (packet.source_channel !== sourceChannel) { | ||
const { source_channel: actual } = packet; | ||
trace('unexpected channel', { actual, expected: sourceChannel }); | ||
return; | ||
} | ||
|
||
// TODO: why is it safe to cast this without a runtime check? | ||
const tx = /** @type {FungibleTokenPacketData} */ ( | ||
JSON.parse(atob(packet.data)) | ||
); | ||
|
||
// given the sourceChannel check, we can be certain of this cast | ||
/** @type {NobleAddress} */ (tx.sender), | ||
BigInt(tx.amount), | ||
); | ||
if (!hasPendingSettlement) { | ||
// TODO FAILURE PATH -> put money in recovery account or .transfer to receiver | ||
// TODO should we have an ORPHANED TxStatus for this? | ||
throw makeError( | ||
`🚨 No pending settlement found for ${q(tx.sender)} ${q(tx.amount)}`, | ||
const sender = /** @type {NobleAddress} */ (tx.sender); | ||
|
||
if (tx.denom !== remoteDenom) { | ||
const { denom: actual } = tx; | ||
trace('unexpected denom', { actual, expected: remoteDenom }); | ||
return; | ||
} | ||
|
||
if (!addressTools.hasQueryParams(tx.receiver)) { | ||
console.log('not query params', tx.receiver); | ||
return; | ||
} | ||
|
||
const { EUD } = addressTools.getQueryParams(tx.receiver); | ||
if (!EUD) { | ||
console.log('no EUD parameter', tx.receiver); | ||
return; | ||
} | ||
|
||
const amount = BigInt(tx.amount); // TODO: what if this throws? | ||
|
||
const { self } = this.facets; | ||
const found = statusManager.dequeueStatus(sender, amount); | ||
trace('dequeued', found, 'for', sender, amount); | ||
switch (found?.status) { | ||
case PendingTxStatus.Advanced: | ||
return self.disburse(found.txHash, sender, amount); | ||
|
||
case PendingTxStatus.Advancing: | ||
this.state.mintedEarly.add(makeMintedEarlyKey(sender, amount)); | ||
return; | ||
|
||
case undefined: | ||
case PendingTxStatus.Observed: | ||
case PendingTxStatus.AdvanceFailed: | ||
default: | ||
return self.forward(found?.txHash, sender, amount, EUD); | ||
} | ||
}, | ||
}, | ||
notify: { | ||
/** | ||
* @param {EvmHash} txHash | ||
* @param {NobleAddress} sender | ||
* @param {NatValue} amount | ||
* @param {string} EUD | ||
* @param {boolean} success | ||
* @returns {void} | ||
*/ | ||
notifyAdvancingResult(txHash, sender, amount, EUD, success) { | ||
const { mintedEarly } = this.state; | ||
const key = makeMintedEarlyKey(sender, amount); | ||
if (mintedEarly.has(key)) { | ||
mintedEarly.delete(key); | ||
if (success) { | ||
void this.facets.self.disburse(txHash, sender, amount); | ||
} else { | ||
void this.facets.self.forward(txHash, sender, amount, EUD); | ||
} | ||
} else { | ||
statusManager.advanceOutcome(sender, amount, success); | ||
} | ||
}, | ||
}, | ||
self: { | ||
/** | ||
* @param {EvmHash} txHash | ||
* @param {NobleAddress} sender | ||
* @param {NatValue} amount | ||
*/ | ||
async disburse(txHash, sender, amount) { | ||
const { repayer, settlementAccount } = this.state; | ||
const received = AmountMath.make(USDC, amount); | ||
const { zcfSeat: settlingSeat } = zcf.makeEmptySeatKit(); | ||
const { calculateSplit } = makeFeeTools(feeConfig); | ||
const split = calculateSplit(received); | ||
trace('disbursing', split); | ||
|
||
// TODO: what if this throws? | ||
// arguably, it cannot. Even if deposits | ||
// and notifications get out of order, | ||
// we don't ever withdraw more than has been deposited. | ||
await vowTools.when( | ||
withdrawToSeat( | ||
// @ts-expect-error Vow vs. Promise stuff. TODO: is this OK??? | ||
settlementAccount, | ||
settlingSeat, | ||
harden({ In: received }), | ||
), | ||
); | ||
zcf.atomicRearrange( | ||
harden([[settlingSeat, settlingSeat, { In: received }, split]]), | ||
); | ||
} | ||
repayer.repay(settlingSeat, split); | ||
|
||
// TODO disperse funds | ||
// ~1. fee to contractFeeAccount | ||
// ~2. remainder in poolAccount | ||
// update status manager, marking tx `SETTLED` | ||
statusManager.disbursed(txHash, sender, amount); | ||
}, | ||
/** | ||
* @param {EvmHash | undefined} txHash | ||
* @param {NobleAddress} sender | ||
* @param {NatValue} amount | ||
* @param {string} EUD | ||
*/ | ||
forward(txHash, sender, amount, EUD) { | ||
const { settlementAccount } = this.state; | ||
|
||
// update status manager, marking tx `SETTLED` | ||
statusManager.settle( | ||
/** @type {NobleAddress} */ (tx.sender), | ||
BigInt(tx.amount), | ||
); | ||
const dest = chainHub.makeChainAddress(EUD); | ||
|
||
// TODO? statusManager.forwarding(txHash, sender, amount); | ||
const txfrV = E(settlementAccount).transfer( | ||
dest, | ||
AmountMath.make(USDC, amount), | ||
); | ||
void vowTools.watch(txfrV, this.facets.transferHandler, { | ||
txHash, | ||
sender, | ||
amount, | ||
}); | ||
}, | ||
}, | ||
transferHandler: { | ||
/** | ||
* @param {unknown} result | ||
* @param {SettlerTransferCtx} ctx | ||
* | ||
* @typedef {{ | ||
* txHash: EvmHash; | ||
* sender: NobleAddress; | ||
* amount: NatValue; | ||
* }} SettlerTransferCtx | ||
*/ | ||
onFulfilled(result, ctx) { | ||
const { txHash, sender, amount } = ctx; | ||
statusManager.forwarded(txHash, sender, amount); | ||
}, | ||
/** | ||
* @param {unknown} _result | ||
* @param {SettlerTransferCtx} _ctx | ||
*/ | ||
onRejected(_result, _ctx) { | ||
// const { txHash, sender, amount } = ctx; | ||
// TODO: statusManager.forwardFailed(txHash, sender, amount); | ||
}, | ||
}, | ||
}, | ||
{ | ||
stateShape: harden({ | ||
repayer: M.remotable('Repayer'), | ||
settlementAccount: M.remotable('Account'), | ||
registration: M.or(M.undefined(), M.remotable('Registration')), | ||
sourceChannel: M.string(), | ||
remoteDenom: M.string(), | ||
mintedEarly: M.remotable('mintedEarly'), | ||
}), | ||
}, | ||
); | ||
}; | ||
harden(prepareSettler); | ||
|
||
/** | ||
* XXX consider using pickFacet (do we have pickFacets?) | ||
* @typedef {ReturnType<ReturnType<typeof prepareSettler>>} SettlerKit | ||
*/ |
Oops, something went wrong.