diff --git a/cards/README.md b/cards/README.md index 502e07f..5da74b8 100644 --- a/cards/README.md +++ b/cards/README.md @@ -18,7 +18,9 @@ src/ CardSpender.sol One program: public spends, and the Zone router's spender for private ones CardFactory.sol Deploys programs at addresses fixed by their ID, provider and router factory CardSpend.sol The one struct public and private spends settle - interfaces/ ICardProvider and ICardProviderPayout (the provider engine), the Earn vault subset + SavingsFacility.sol Pays the issuer in cash for Earn shares a hold captured, redeems them into its float + interfaces/ ICardProvider and ICardProviderPayout (the provider engine), the Earn vault subset, + IReservations (the account reservations precompile subset) providers/ Ours, one directory per card provider bridge/ BridgeProvider, the "TS" handler (SpenderTempoSpend), Bridge interfaces rain/ RainProvider, Rain interfaces @@ -27,7 +29,8 @@ vendor/ Providers' own contracts, unmodified (VENDORED.md) rain/ Rain's collateral, factory and controller (verified Polygon sources) tempo-zones/ The Zones runtime the Bridge harness runs test/ - core/ CardSpender paying directly and through a payout target, CardFactory, mock providers + core/ CardSpender paying directly and through a payout target, CardFactory, SavingsFacility, + mock providers and reservations bridge/ Bridge harness, rails, card scenario matrix, unit, fork and smoke tests rain/ Rain harness, unit and lifecycle tests, Rain bytecode fixture script/ diff --git a/cards/src/core/SavingsFacility.sol b/cards/src/core/SavingsFacility.sol new file mode 100644 index 0000000..a5a637a --- /dev/null +++ b/cards/src/core/SavingsFacility.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { IEarnVault } from "tempo-earn/interfaces/core/IEarnVault.sol"; +import { IERC20Like } from "tempo-earn/interfaces/external/IERC20Like.sol"; + +import { CARDS_ADDRESS, ICards } from "./interfaces/ICards.sol"; +import { IReservations, RESERVATIONS_ADDRESS } from "./interfaces/IReservations.sol"; + +/// @title SavingsFacility +/// @notice Pays a card issuer at once, in cash, for Tempo Earn shares a cardholder's hold captured, and redeems +/// those shares to refill its float. It is the payee of every share hold its program places, so a hold +/// that spans cash and savings settles as a cash capture to the issuer plus a share capture here. It +/// also pays out, in cash, shares an account's sweep (closure, estate or unclaimed property) paid in. +/// Specified in docs/cards/design.md (Spend from savings). +/// @dev Deployed per program with a fixed vault, issuer, settlement account and funder, and never changed. The +/// funder supplies the float and owns what is left in it. Shares are settled once per hold, for what the +/// precompile records as captured; share holds take no refunds (refunds go through the cash path). +/// Settlement prices shares with `previewRedeem`, so it needs a vault that can quote one; while it cannot (an +/// async-only engine, a paused or stale price), `settle` reverts and the shares wait here. Redemption is best +/// effort: when the vault quotes but cannot redeem at once, or would return less than the issuer is paid, +/// the shares stay here, the float pays the issuer, and the funder redeems them later with `redeemHeld`. +/// Payouts never touch the float: they redeem straight to the recipient or revert, and swept shares waiting +/// for a payout are not the funder's to redeem or withdraw. +contract SavingsFacility { + IReservations internal constant RESERVATIONS = IReservations(RESERVATIONS_ADDRESS); + ICards internal constant CARDS = ICards(CARDS_ADDRESS); + + IEarnVault public immutable VAULT; + /// @notice The vault's Earn share, the token of the holds this facility settles. + address public immutable SHARE; + /// @notice The vault's base asset, which the facility pays out. + address public immutable ASSET; + /// @notice The only caller of `settle`: the program's issuer. + address public immutable ISSUER; + /// @notice Where every settlement is paid. + address public immutable SETTLEMENT_ACCOUNT; + /// @notice Supplies the float and may withdraw it and redeem shares left after a deferred redemption. + address public immutable FUNDER; + + /// @notice Captured shares already settled, per owner and hold id. + mapping(address owner => mapping(bytes32 id => uint256 shares)) public settled; + /// @notice Swept shares already paid out, per owner. + mapping(address owner => uint256 shares) public paidOut; + /// @notice Shares deferred settlements left here, the only shares the funder may redeem or withdraw. + uint256 public heldShares; + + bool private transient _locked; + + event Settled(address indexed owner, bytes32 indexed id, uint256 shares, uint256 assets); + event PaidOut(address indexed owner, address indexed to, uint256 shares, uint256 assets); + event Redeemed(uint256 shares, uint256 assets); + event RedemptionDeferred(uint256 shares); + + error AssetsBelowMinimum(uint256 assets, uint256 minAssets); + error ExceedsHeldShares(uint256 shares, uint256 held); + error NoQuote(); + error NotFacilityHold(); + error NotFunder(); + error NotIssuer(); + error NothingToSettle(); + error OwnerOwesShares(uint256 debt); + error Reentrancy(); + error ZeroAddress(); + + modifier nonReentrant() { + if (_locked) revert Reentrancy(); + _locked = true; + _; + _locked = false; + } + + constructor(IEarnVault vault, address issuer, address settlementAccount, address funder) { + if ( + address(vault) == address(0) || issuer == address(0) || settlementAccount == address(0) + || funder == address(0) + ) revert ZeroAddress(); + VAULT = vault; + SHARE = vault.earnShare(); + ASSET = vault.asset(); + ISSUER = issuer; + SETTLEMENT_ACCOUNT = settlementAccount; + FUNDER = funder; + } + + /// @notice Settles the shares `owner`'s hold `id` captured since its last settlement: pays their current value + /// in the base asset to the settlement account and redeems them. + /// @param minAssets The least the issuer accepts for the shares; reverts below it. + /// @return shares The shares settled. + /// @return assets What the settlement account was paid. + function settle(address owner, bytes32 id, uint256 minAssets) + external + nonReentrant + returns (uint256 shares, uint256 assets) + { + if (msg.sender != ISSUER) revert NotIssuer(); + IReservations.Hold memory hold = RESERVATIONS.getHold(owner, id); + if (hold.token != SHARE || hold.payee != address(this)) revert NotFacilityHold(); + // A capture beyond the hold may be partly debt to this facility, which `captured` still counts as paid. + // Debt is per owner, not per hold, so no hold of a debtor settles until the debt is repaid. + (address creditor, uint128 debt) = RESERVATIONS.debtOf(owner, SHARE); + if (creditor == address(this) && debt != 0) revert OwnerOwesShares(debt); + (uint128 captured,) = RESERVATIONS.captured(owner, id); + uint256 done = settled[owner][id]; + if (captured <= done) revert NothingToSettle(); + shares = captured - done; + settled[owner][id] = captured; + + // Without a quote there is no safe price to pay, so this reverts rather than trusting a caller value. + assets = VAULT.previewRedeem(shares); + if (assets < minAssets) revert AssetsBelowMinimum(assets, minAssets); + // Redeem for at least what the issuer is paid, so a settlement never shrinks the float. + _redeem(shares, assets); + SafeTransferLib.safeTransfer(ASSET, SETTLEMENT_ACCOUNT, assets); + emit Settled(owner, id, shares, assets); + } + + /// @notice Pays out shares that sweeps of `owner`'s account paid in since the last payout, redeeming them straight + /// to the recipient the card records name. Anyone may call it, since the records alone decide who is paid; + /// while the vault cannot quote or redeem them it reverts, and the shares wait here for a later call. + /// @return shares The shares paid out. + /// @return assets What the recipient was paid. + function payOut(address owner) external nonReentrant returns (uint256 shares, uint256 assets) { + (address to, uint128 swept) = CARDS.payoutOf(owner, SHARE, address(this)); + if (to == address(0)) revert ZeroAddress(); + uint256 done = paidOut[owner]; + if (swept <= done) revert NothingToSettle(); + shares = swept - done; + paidOut[owner] = swept; + // The quote is the floor: a redemption returning less, or no quote at all, reverts and nothing is paid. + uint256 quote = VAULT.previewRedeem(shares); + if (quote == 0) revert NoQuote(); + SafeTransferLib.safeApprove(SHARE, address(VAULT), shares); + // Every entry point holds the transient lock for this whole call. + // forge-lint: disable-next-line(reentrancy-no-eth) + assets = VAULT.redeem(shares, to, quote); + emit PaidOut(owner, to, shares, assets); + } + + /// @notice Redeems shares a deferred redemption left here, into the float. + function redeemHeld(uint256 shares, uint256 minAssets) external nonReentrant returns (uint256 assets) { + if (msg.sender != FUNDER) revert NotFunder(); + _takeHeld(shares); + SafeTransferLib.safeApprove(SHARE, address(VAULT), shares); + // Every entry point holds the transient lock for this whole call. + // forge-lint: disable-next-line(reentrancy-no-eth) + assets = VAULT.redeem(shares, address(this), minAssets); + emit Redeemed(shares, assets); + } + + /// @notice Withdraws float or held shares to `to`; swept shares awaiting a payout stay. + function withdraw(address token, address to, uint256 amount) external nonReentrant { + if (msg.sender != FUNDER) revert NotFunder(); + if (token == SHARE) _takeHeld(amount); + SafeTransferLib.safeTransfer(token, to, amount); + } + + /// @notice The base asset available to pay settlements. + function float() external view returns (uint256) { + return IERC20Like(ASSET).balanceOf(address(this)); + } + + /// @dev Redeems into the float, or leaves the shares here when the vault cannot redeem them now. + function _redeem(uint256 shares, uint256 minAssets) private { + // The vault pulls the shares; approve exactly them and clear what a failed redemption left. + SafeTransferLib.safeApprove(SHARE, address(VAULT), shares); + // Every entry point holds the transient lock for this whole call. + // forge-lint: disable-next-line(reentrancy-no-eth) + try VAULT.redeem(shares, address(this), minAssets) returns (uint256 assets) { + emit Redeemed(shares, assets); + } catch { + SafeTransferLib.safeApprove(SHARE, address(VAULT), 0); + heldShares += shares; + emit RedemptionDeferred(shares); + } + } + + /// @dev Releases deferred shares to the funder, reverting past what deferred settlements left. + function _takeHeld(uint256 shares) private { + uint256 held = heldShares; + if (shares > held) revert ExceedsHeldShares(shares, held); + heldShares = held - shares; + } +} diff --git a/cards/src/core/interfaces/ICards.sol b/cards/src/core/interfaces/ICards.sol new file mode 100644 index 0000000..1e4de68 --- /dev/null +++ b/cards/src/core/interfaces/ICards.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +/// @dev Tempo's card records precompile (ASCII "CARD", TIP-1169). +address constant CARDS_ADDRESS = 0x4341524400000000000000000000000000000000; + +/// @notice The subset of the card records precompile a savings facility reads: what an account's sweeps paid +/// into a facility for redemption, and who receives the cash. +interface ICards { + /// @notice Shares of `token` that sweeps of `owner`'s account paid into `via` so far, and their recipient. + function payoutOf(address owner, address token, address via) external view returns (address to, uint128 amount); +} diff --git a/cards/src/core/interfaces/IReservations.sol b/cards/src/core/interfaces/IReservations.sol new file mode 100644 index 0000000..b3c7903 --- /dev/null +++ b/cards/src/core/interfaces/IReservations.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +/// @dev Tempo's account reservations precompile (ASCII "RESV"), from T15 (TIP-1153). +address constant RESERVATIONS_ADDRESS = 0x5245535600000000000000000000000000000000; + +/// @notice The subset of the account reservations precompile a card settlement reads: native holds on TIP-20 +/// balances, their captured totals, and capped debt. Hold ids are scoped per owner. +interface IReservations { + /// @notice A hold on `amount` of the owner's `token` balance in favor of `payee`. A captured hold keeps its + /// token and payee with `amount` zero. + struct Hold { + address token; + address payee; + uint128 amount; + uint64 expiresAt; + } + + function getHold(address owner, bytes32 id) external view returns (Hold memory); + + /// @notice What the hold's capture paid its payee, and how much of that the payee refunded. + function captured(address owner, bytes32 id) external view returns (uint128 captured, uint128 refunded); + + /// @notice The owner's unpaid debt in `token` and who it is owed to (zero when none). + function debtOf(address owner, address token) external view returns (address creditor, uint128 amount); +} diff --git a/cards/test/core/SavingsFacility.t.sol b/cards/test/core/SavingsFacility.t.sol new file mode 100644 index 0000000..2a2aec2 --- /dev/null +++ b/cards/test/core/SavingsFacility.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import { IEarnVault } from "tempo-earn/interfaces/core/IEarnVault.sol"; + +import { SavingsFacility } from "../../src/core/SavingsFacility.sol"; +import { CARDS_ADDRESS } from "../../src/core/interfaces/ICards.sol"; +import { IReservations, RESERVATIONS_ADDRESS } from "../../src/core/interfaces/IReservations.sol"; +import { CoreEnv } from "./harness/CoreEnv.sol"; +import { MockCards } from "./harness/MockCards.sol"; +import { MockReservations } from "./harness/MockReservations.sol"; + +/// @notice `SavingsFacility` over a real EarnVault and TIP-20 precompiles, with reservations state written +/// directly: a share hold captured to the facility, settled by the issuer. +contract SavingsFacilityTest is CoreEnv { + MockReservations internal reservations = MockReservations(RESERVATIONS_ADDRESS); + MockCards internal cards = MockCards(CARDS_ADDRESS); + SavingsFacility internal facility; + + address internal issuer = makeAddr("issuer"); + address internal settlement = makeAddr("issuer.settlement"); + address internal funder = makeAddr("funder"); + bytes32 internal constant ID = bytes32("auth-1"); + + uint256 internal constant FLOAT = 10_000e6; + + function setUp() public { + _deployCore(); + vm.etch(RESERVATIONS_ADDRESS, address(new MockReservations()).code); + vm.etch(CARDS_ADDRESS, address(new MockCards()).code); + facility = new SavingsFacility(IEarnVault(address(earnVault)), issuer, settlement, funder); + usd.mint(address(facility), FLOAT); + usd.mint(alice, 1000e6); + _depositEarn(alice, 1000e6); + } + + function test_settle_paysValueAndRefillsFloat() public { + uint256 shares = _captureShares(alice, ID, earnVault.previewWithdraw(300e6)); + uint256 value = earnVault.previewRedeem(shares); + + vm.prank(issuer); + (uint256 settledShares, uint256 assets) = facility.settle(alice, ID, value); + + assertEq(settledShares, shares); + assertEq(assets, value); + assertEq(usd.balanceOf(settlement), value); + assertEq(earnShare.balanceOf(address(facility)), 0); + // Redemption returns what the issuer was paid, so the float is whole again (to rounding). + assertApproxEqAbs(facility.float(), FLOAT, 1); + assertEq(facility.settled(alice, ID), shares); + } + + function test_payOut_paysSweptSharesToTheRecipient() public { + address state = makeAddr("state"); + uint256 shares = earnShare.balanceOf(alice); + _sweepShares(alice, state, shares); + uint256 value = earnVault.previewRedeem(shares); + + (uint256 paidShares, uint256 assets) = facility.payOut(alice); + + assertEq(paidShares, shares); + assertEq(assets, value); + assertEq(usd.balanceOf(state), value); + assertEq(earnShare.balanceOf(address(facility)), 0); + assertApproxEqAbs(facility.float(), FLOAT, 1); + assertEq(facility.paidOut(alice), shares); + } + + function test_payOut_onlyNewSweeps() public { + address state = makeAddr("state"); + _sweepShares(alice, state, 100e6); + facility.payOut(alice); + vm.expectRevert(SavingsFacility.NothingToSettle.selector); + facility.payOut(alice); + // A later sweep pays only its own shares. + _sweepShares(alice, state, 50e6); + (uint256 paidShares,) = facility.payOut(alice); + assertEq(paidShares, 50e6); + } + + function test_payOut_needsARecipient() public { + vm.expectRevert(SavingsFacility.ZeroAddress.selector); + facility.payOut(alice); + } + + function test_payOut_revertsWhenVaultCannotRedeem() public { + address state = makeAddr("state"); + uint256 shares = 200e6; + _sweepShares(alice, state, shares); + uint256 value = earnVault.previewRedeem(shares); + bytes memory redeemCall = abi.encodeWithSignature("redeem(uint256,address,uint256)", shares, state, value); + vm.mockCallRevert(address(earnVault), redeemCall, "illiquid"); + + vm.expectRevert(bytes("illiquid")); + facility.payOut(alice); + // Nothing moved: the float is untouched and the payout can run once the vault redeems. + assertEq(facility.float(), FLOAT); + assertEq(facility.paidOut(alice), 0); + assertEq(earnShare.balanceOf(address(facility)), shares); + + vm.clearMockedCalls(); + (, uint256 assets) = facility.payOut(alice); + assertEq(usd.balanceOf(state), assets); + assertGe(assets, value); + assertEq(facility.float(), FLOAT); + } + + function test_payOut_revertsWithoutQuote() public { + uint256 shares = 100e6; + _sweepShares(alice, makeAddr("state"), shares); + vm.mockCall(address(earnVault), abi.encodeWithSignature("previewRedeem(uint256)", shares), abi.encode(0)); + vm.expectRevert(SavingsFacility.NoQuote.selector); + facility.payOut(alice); + assertEq(facility.paidOut(alice), 0); + } + + function test_funderCannotTakeSweptShares() public { + uint256 shares = 100e6; + _sweepShares(alice, makeAddr("state"), shares); + vm.startPrank(funder); + vm.expectRevert(abi.encodeWithSelector(SavingsFacility.ExceedsHeldShares.selector, shares, 0)); + facility.withdraw(address(earnShare), funder, shares); + vm.expectRevert(abi.encodeWithSelector(SavingsFacility.ExceedsHeldShares.selector, shares, 0)); + facility.redeemHeld(shares, 1); + vm.stopPrank(); + // The float itself stays the funder's. + vm.prank(funder); + facility.withdraw(address(usd), funder, 1); + } + + function test_settle_onlyNewCaptures() public { + _captureShares(alice, ID, 100e6); + vm.startPrank(issuer); + facility.settle(alice, ID, 0); + vm.expectRevert(SavingsFacility.NothingToSettle.selector); + facility.settle(alice, ID, 0); + vm.stopPrank(); + } + + function test_settle_onlyIssuer() public { + _captureShares(alice, ID, 100e6); + vm.expectRevert(SavingsFacility.NotIssuer.selector); + facility.settle(alice, ID, 0); + } + + function test_settle_rejectsCashHold() public { + reservations.setHold(alice, ID, IReservations.Hold(address(usd), address(facility), 0, 0)); + reservations.setCaptured(alice, ID, 100e6); + vm.prank(issuer); + vm.expectRevert(SavingsFacility.NotFacilityHold.selector); + facility.settle(alice, ID, 0); + } + + function test_settle_rejectsHoldForAnotherPayee() public { + reservations.setHold(alice, ID, IReservations.Hold(address(earnShare), settlement, 0, 0)); + reservations.setCaptured(alice, ID, 100e6); + vm.prank(issuer); + vm.expectRevert(SavingsFacility.NotFacilityHold.selector); + facility.settle(alice, ID, 0); + } + + function test_settle_rejectsOwnerInDebtToFacility() public { + _captureShares(alice, ID, 100e6); + reservations.setDebt(alice, address(earnShare), address(facility), 5e6); + vm.prank(issuer); + vm.expectRevert(abi.encodeWithSelector(SavingsFacility.OwnerOwesShares.selector, 5e6)); + facility.settle(alice, ID, 0); + } + + function test_settle_ignoresDebtToOthers() public { + _captureShares(alice, ID, 100e6); + reservations.setDebt(alice, address(earnShare), bob, 5e6); + vm.prank(issuer); + facility.settle(alice, ID, 0); + } + + function test_settle_enforcesMinAssets() public { + uint256 shares = _captureShares(alice, ID, 100e6); + uint256 value = earnVault.previewRedeem(shares); + vm.prank(issuer); + vm.expectRevert(abi.encodeWithSelector(SavingsFacility.AssetsBelowMinimum.selector, value, value + 1)); + facility.settle(alice, ID, value + 1); + } + + function test_settle_deferredRedemptionPaysFromFloat() public { + uint256 shares = _captureShares(alice, ID, 200e6); + uint256 value = earnVault.previewRedeem(shares); + bytes memory redeemCall = + abi.encodeWithSignature("redeem(uint256,address,uint256)", shares, address(facility), value); + vm.mockCallRevert(address(earnVault), redeemCall, "illiquid"); + + vm.prank(issuer); + facility.settle(alice, ID, value); + assertEq(usd.balanceOf(settlement), value); + assertEq(earnShare.balanceOf(address(facility)), shares); + assertEq(earnShare.allowance(address(facility), address(earnVault)), 0); + assertEq(facility.float(), FLOAT - value); + assertEq(facility.heldShares(), shares); + + vm.clearMockedCalls(); + vm.prank(funder); + facility.redeemHeld(shares, 1); + assertEq(facility.heldShares(), 0); + assertEq(earnShare.balanceOf(address(facility)), 0); + assertApproxEqAbs(facility.float(), FLOAT, 1); + } + + function test_settle_revertsWithoutQuote() public { + uint256 shares = _captureShares(alice, ID, 200e6); + vm.mockCallRevert(address(earnVault), abi.encodeWithSignature("previewRedeem(uint256)", shares), "no quote"); + vm.prank(issuer); + vm.expectRevert(bytes("no quote")); + facility.settle(alice, ID, 0); + assertEq(facility.settled(alice, ID), 0); + assertEq(earnShare.balanceOf(address(facility)), shares); + assertEq(facility.float(), FLOAT); + } + + function test_settle_revertsWithoutFloatWhenDeferred() public { + vm.prank(funder); + facility.withdraw(address(usd), funder, FLOAT); + uint256 shares = _captureShares(alice, ID, 200e6); + vm.mockCallRevert( + address(earnVault), + abi.encodeWithSignature( + "redeem(uint256,address,uint256)", shares, address(facility), earnVault.previewRedeem(shares) + ), + "illiquid" + ); + vm.prank(issuer); + vm.expectRevert(); + facility.settle(alice, ID, 0); + assertEq(facility.settled(alice, ID), 0); + } + + function test_funderOnly() public { + vm.expectRevert(SavingsFacility.NotFunder.selector); + facility.withdraw(address(usd), address(this), 1); + vm.expectRevert(SavingsFacility.NotFunder.selector); + facility.redeemHeld(1, 0); + } + + function test_constructor_rejectsZeroAddress() public { + vm.expectRevert(SavingsFacility.ZeroAddress.selector); + new SavingsFacility(IEarnVault(address(earnVault)), address(0), settlement, funder); + } + + /// @dev What a capture of `shares` on a share hold leaves: the hold, its captured total, the shares paid. + function _captureShares(address owner, bytes32 id, uint256 shares) internal returns (uint256) { + reservations.setHold(owner, id, IReservations.Hold(address(earnShare), address(facility), 0, 0)); + reservations.setCaptured(owner, id, uint128(shares)); + vm.prank(owner); + earnShare.transfer(address(facility), shares); + return shares; + } + + /// @dev Moves `shares` of `owner`'s into the facility and adds them to the payout record, as a sweep does. + function _sweepShares(address owner, address to, uint256 shares) internal { + (, uint128 swept) = cards.payoutOf(owner, address(earnShare), address(facility)); + vm.prank(owner); + earnShare.transfer(address(facility), shares); + cards.setPayout(owner, address(earnShare), address(facility), to, swept + uint128(shares)); + } +} diff --git a/cards/test/core/harness/MockCards.sol b/cards/test/core/harness/MockCards.sol new file mode 100644 index 0000000..ae74fb7 --- /dev/null +++ b/cards/test/core/harness/MockCards.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import { ICards } from "../../../src/core/interfaces/ICards.sol"; + +/// @notice Stands in for the card records precompile, which the test EVM's hardfork predates. Etched at its +/// address; tests write the payout record a sweep leaves and move the shares themselves. +contract MockCards is ICards { + mapping(address => mapping(address => mapping(address => address))) private _to; + mapping(address => mapping(address => mapping(address => uint128))) private _amount; + + function setPayout(address owner, address token, address via, address to, uint128 amount) external { + _to[owner][token][via] = to; + _amount[owner][token][via] = amount; + } + + function payoutOf(address owner, address token, address via) external view returns (address, uint128) { + return (_to[owner][token][via], _amount[owner][token][via]); + } +} diff --git a/cards/test/core/harness/MockReservations.sol b/cards/test/core/harness/MockReservations.sol new file mode 100644 index 0000000..b1e4acc --- /dev/null +++ b/cards/test/core/harness/MockReservations.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import { IReservations } from "../../../src/core/interfaces/IReservations.sol"; + +/// @notice Stands in for the account reservations precompile, which the test EVM's hardfork predates. Etched at +/// its address; tests write the state a hold, capture or debt leaves and move the tokens themselves. +contract MockReservations is IReservations { + mapping(address => mapping(bytes32 => Hold)) private _holds; + mapping(address => mapping(bytes32 => uint128)) private _captured; + mapping(address => mapping(address => address)) private _creditors; + mapping(address => mapping(address => uint128)) private _debts; + + function setHold(address owner, bytes32 id, Hold calldata hold) external { + _holds[owner][id] = hold; + } + + function setCaptured(address owner, bytes32 id, uint128 amount) external { + _captured[owner][id] = amount; + } + + function setDebt(address owner, address token, address creditor, uint128 amount) external { + _creditors[owner][token] = creditor; + _debts[owner][token] = amount; + } + + function getHold(address owner, bytes32 id) external view returns (Hold memory) { + return _holds[owner][id]; + } + + function captured(address owner, bytes32 id) external view returns (uint128, uint128) { + return (_captured[owner][id], 0); + } + + function debtOf(address owner, address token) external view returns (address, uint128) { + return (_creditors[owner][token], _debts[owner][token]); + } +} diff --git a/docs/cards/design.md b/docs/cards/design.md index 8f1ca02..f12864a 100644 --- a/docs/cards/design.md +++ b/docs/cards/design.md @@ -15,6 +15,7 @@ shared design; [bridge/](bridge/README.md) and [rain/](rain/README.md) own each | [`CardFactory`](../../cards/src/core/CardFactory.sol) | None | Deploys a program at a CREATE2 address fixed by its ID, provider and router factory. | | [`ICardProvider`](../../cards/src/core/interfaces/ICardProvider.sol) engine | The provider's contracts | Decides destinations, Earn vaults and the provider's pause; optionally names what the program pays through. | | [`ZoneRouter`](../../zone-router/src/ZoneRouter.sol) (Spend op) | None | Receives the Zone withdrawal, hands `maxInput` to the program (its listed spender), returns the change. | +| [`SavingsFacility`](../../cards/src/core/SavingsFacility.sol) | None (a fixed funder) | Pays the issuer in cash for Earn shares a hold captured, then redeems them into its float. | | Provider | Provider contract | How the program pays | | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | @@ -96,6 +97,36 @@ TIP-1000). Anyone may also send a program one unit of each token it handles: `sp program holds as its baseline and leaves it there, so a spend never creates the program's balance slot (about 250k gas). The Rain E2E requests 3,000,000. +## Spend from savings + +With account reservations (TIP-1153, from T15) a card authorization is a native hold rather than an +immediate spend, and a hold can span cash and Earn shares: the issuer's access key holds cash first, and +holds shares for the rest in favor of the program's [`SavingsFacility`](../../cards/src/core/SavingsFacility.sol). +Held shares stay in the cardholder's balance and keep earning. At capture the issuer captures cash first +and shares only for the shortfall, so the facility receives the shares, and the issuer calls +`settle(owner, id, minAssets)`: + +- The hold must be on the vault's share with the facility as payee; it settles once, for what the + precompile records as captured. +- The facility pays the settlement account `previewRedeem(shares)` in the base asset at once, then redeems + the shares for at least that amount. When the vault quotes but cannot redeem at once (an illiquid + venue), the shares stay in the facility, the float pays meanwhile, and the funder redeems them later + with `redeemHeld`. Pricing needs a quote: while the vault cannot give one (an async-only engine, a + paused or stale price), `settle` reverts and the captured shares wait in the facility until it can. +- A capture beyond the hold may be partly debt to the facility, which the precompile still counts as + captured, so no hold of an owner in share debt to the facility settles until it is repaid. +- Share holds take no refunds; refunds go through the cash path. +- **Payouts.** A sweep of an account by the card records precompile (TIP-1169: closure, an estate or + unclaimed property) may pay its shares into the facility and record the recipient. Anyone may then call + `payOut(owner)`, which redeems the swept shares straight to the recipient, once per share, for at least + their `previewRedeem` value. The float never pays a payout: while the vault cannot quote or redeem the + shares, `payOut` reverts and they wait in the facility. The funder may redeem or withdraw only shares + deferred settlements left (`heldShares`), never swept shares awaiting a payout. + +The facility has no setter: its vault, issuer, settlement account and funder are fixed at deployment. +The funder supplies the float, bears redemption fees and price moves between capture and redemption, and +may withdraw what the float holds. + ## What the program checks - `CardSpend.program` is this program (`WrongProgram`); `amount` is above zero and `fromVault` at most