Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Payments contract first draft #1

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

aarshkshah1992
Copy link
Collaborator

@aarshkshah1992 aarshkshah1992 commented Feb 7, 2025

This is the first draft for the payments contract MVP. It has the following features:

  • ERC-20 deposits
  • ERC-20 withdrawals
  • rail creation
  • operator approvals
  • operator termination
  • rail payment modification
  • rail lockup modification
  • lazy rail settlement
  • proxy pattern for contract upgrade
  • arbitration
  • rate change queuing
  • avoiding debt creation during lockup or rate modification

All rail modifications and creation respect the operator approval.

The following features which will be added in a subsequent PR:

  1. Escrow
  2. Payment Fees

@aarshkshah1992 aarshkshah1992 changed the title [WIP] Payments contract and proxy scaffold Payments contract first draft Feb 12, 2025
@Stebalien Stebalien self-requested a review February 13, 2025 00:59
src/Payments.sol Outdated
payer.lockupRate = payer.lockupRate - oldRate + rate;

// Init hasLockupStarted flag and lockupStart for the first rail with non-zero rate
if (rate > 0 && !payer.hasLockupStarted) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at the "Lockup can be settled..." part of: https://www.notion.so/filecoindev/Payments-Design-Iteration-17ddc41950c180468806c7e0097c2e14?pvs=4#185dc41950c1805da8d4ce567e343307

lockupStart is probably misnamed. Really, it means "the last time we settled lockup". I.e., it's when the lockup rate starts applying.

My point is, I don't think we need hasLockupStarted. We always adjust lockupStart.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see you've implemented accumulateLockupRate. You don't need this code here, you just need to call accumulateLockupRate before changing the rate (e.g., at the very top of this function). The first time this is called, that will accumulate nothing because the rate will be 0, but it will initialize lockupStart.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien Yeah this makes sense. If totalLockupRate is 0, the lockupstart does not matter.

I'm fixing naming across this contract now for all state. I don't think these names accurately reflect what they're doing. lockupBase for eg dosen't reflect that it's doing three things:

  1. Locking up a fixed security deposit
  2. Locking up accrued payments for services already taken by the client but not yet settled by the provider
  3. Locking up a portion of payments for future services that have yet to be provided to provide some sort of "guarantee" to the provider.

src/Payments.sol Outdated
// 1. Fixed lockups from all rails associated with this account.
// 2. Accumulated rate-based lockups that have been converted to fixed lockups across all rails.
// 3. Funds reserved for future payments based on current payment rates and lockup periods across all rails.
uint256 requiredTotalLockedFunds;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the design doc. I've named this "lockup current" because it covers funds that are actually locked, not the current target locked. I didn't use the word required because the actually locked funds may be less than the "target" (required).

uint256 private _nextRailId;

// token => owner => Account
mapping(address => mapping(address => Account)) public accounts;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're going to need two features WRT account management:

  1. A way to efficiently (ish) determine the status of an account. Total funds across all tokens, etc. so we can display it in some webui.
  2. Ideally a way to efficiently change the owner address? That's complicated.

Even if we don't support (2), we'll need some top-level account abstraction to support (1).

Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien

Can't we implement those getters as view functions that can be invoked off-chain on RPC providers ? We can add some state to the contract that makes those view/getter functions slightly less compute intensive but IMO making it a view function that can be invoked off-chain means we can offload the worrying about efficiency to client <> RPC provider relationships without having to add too much state to the contract.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, we can solve (1) by:

  1. Adding a mapping(address => address[]) of account owners to tokens.
  2. Optionally: changing token => owner => Account to owner => token => Account. This isn't strictly necessary, but it mirrors the mapping in (1).

As discussed, we can drop (2) and let the user handle indirection through smart contracts.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, maybe we want a single owner => Account mapping where Account contains:

  1. The client's operator approvals (map of operator => Approval).
  2. An array of approved client operators.
  3. An array of client rail IDs.
  4. etc...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolution: we're still exploring the API, let's build that first then figure out what these data structures should look at.

function approveOperator(
address token,
address operator,
address arbiter,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure we want to approve a specific arbiter? I can see the appeal but, in that case... I think we can just let the client decide if they're ok with an arbiter on a per-rail basis.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien You mean the client creates the rail OR that a client has to approve a rail that has been created by an operator ? Both have different UX implications.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An operator can have many different arbitrers for different rails.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So remove this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answer here is to trust the operator.


Account storage toAccount = accounts[token][to];
require(toAccount.ownerAddress != address(0), "to account does not exist");
require(toAccount.funds > 0, "to account has no funds");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't need funds, do they?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien I mean not strictly speaking. We can get rid of this yeah.


Account storage fromAccount = accounts[token][from];
require(fromAccount.ownerAddress != address(0), "from account does not exist");
require(fromAccount.funds > 0, "from account has no funds");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, the sender doesn't need any funds either.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien Technically no but I'd like to keep this so that rails are only created with clients who have interacted with the contract before. Just think it's better that way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this goes back to who can create a rail. IMO, the answer is: the client.

It would be nice if a client can go through the entire signup flow without having to deposit any funds until they actually start using the service.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agree that neither from or to needs funds.

Comment on lines +226 to +228
if (approval.arbitrer != address(0)) {
require(arbiter == approval.arbitrer, "arbiter mismatch");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBD: need to discuss approved operators and who, precisely, is allowed to call createRail.


uint256 railId = _nextRailId++;

Rail storage rail = rails[railId];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OT: Ethereum's "everything exists, it just defaults to zero" gets me every time. It's kind of cool but also really disconcerting.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien What's "OT" ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Off topic". I.e., "nothing to do with my review, ignore this, I just wanted to say it."

Comment on lines +6 to +8
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just ignore all of /broadcast?

src/Payments.sol Outdated
struct ArbitrationResult {
bool approved;
uint256 modifiedAmount;
uint256 settledUntil;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider adding an arbitrary "note" field. Could be useful to explain why the full amount isn't approved.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this and also now returning this to the caller of settleRail


interface IArbiter {
struct ArbitrationResult {
bool approved;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need approved? IMO, we should just revert if we're not approving anything, but I'm not really a solidity expert.

Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien This is for the Arbitrer contract to gracefully let us know that it is/is not rejecting the payment.

The Payment contract does revert the settlement if it sees a disapproval from the Arbitrer but doing it this way lets us decide what we wanna do when we see a rejection from the Arbitrer.

We can change this so that the Arbitrer reverts rather than returning this approved flag and then handle the revert in the Payment contract as follows:

// Low-level call with error handling
(bool success, bytes memory returnData) = IArbitrer(Y).call(....);
if (!success) {
    // Handle the error
}

but we need to write some additional encoding/decoding code here for serialisation.

No opinion either way. Wdyt ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option is to not gracefully handle any reverts from the Arbitrer contract at all and simply revert right away (in Solidity, when contract X calls contract Y and contract Y reverts -> the call to X reverts right away unless you handle it as above).

The way we've implemented it here -> any revert from Arbitrer will bubble up right away and the settlement will revert but atleast for payment refusals -> we get to handle it gracefully and do more stuff if we want to before we revert.

Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Arbitrer MUST revert if it wants to reject payment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if arbitrer wants to reject and NOT revert, you can just set settleUpto to the start epoch.

src/Payments.sol Outdated
uint256 paymentRate;
uint256 lockupPeriod;
uint256 lockupFixed;
uint256 lastSettledAt;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Now that I see this, I wonder if we should call this "settledUpTo" instead of "lastSettledAt".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also renamed the corresponding ArbitrationResult field to settledUpTo

}

struct OperatorApproval {
bool isApproved;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this field?

Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien Yeah it's the easiest way to track the fact that an operator has been disapproved. Solidity HashMap/Array deletes aren't clean/gas cheap to do.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, if operators create rails, it makes sense to:

  1. Approve the operator with no allowance.
  2. Let the operator create rails.
  3. Then give them allowance.

(IMO)


struct OperatorApproval {
bool isApproved;
address arbitrer;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this field either? Isn't it implicit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien My idea was to have the client have a say in who gets to be an arbitrer.

src/Payments.sol Outdated
Comment on lines 527 to 530
/// @notice Returns the minimum of two numbers.
function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this a free function?

src/Payments.sol Outdated
Comment on lines 476 to 477
}
function _settleWithRateChanges(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
function _settleWithRateChanges(
}
function _settleWithRateChanges(

src/Payments.sol Outdated
uint256 oldLockup = rail.lockupFixed + (rail.paymentRate * rail.lockupPeriod);
uint256 newLockup = lockupFixed + (rail.paymentRate * period);

// assert that operator allownace is respected
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// assert that operator allownace is respected
// assert that operator allowance is respected

src/Payments.sol Outdated
segmentStart,
segmentEnd
);
require(arbResult.approved, "arbitrer refused payment");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to validate that:

  1. arbResult.settledUntil <= settledUntil.
  2. arbResult.modifiedAmount <= rate * (arbResult.settledUntil - segmentStart).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For 1, it should be arbResult.settledUntil <=segmentEnd, right ? Yeah making this change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

src/Payments.sol Outdated

settleAccountLockup(payer);

uint256 settlementTargetEpoch = min(untilEpoch, payer.lockupLastSettledAt + rail.lockupPeriod);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check that untilEpoch is at or before the current epoch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

src/Payments.sol Outdated
Comment on lines 494 to 497
if (nextChange.untilEpoch > currentEpoch && nextChange.untilEpoch <= targetEpoch) {
nextBoundary = nextChange.untilEpoch;
segmentRate = nextChange.rate;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've spent a lot of time staring at this code and I'm still not sure if it's right. Let's talk through it tomorrow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. It is involved.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me try to simplify it more today before we sync up.

bool approved;
uint256 modifiedAmount;
uint256 settleUpto;
string note;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can leave a note if successful too. For better UX, it's a catch all field for arbitrer to send us info.


}

function updateRailArbiter(uint256 railId, address newArbiter) external validateRailActive(railId) onlyRailOperator(railId) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this ?

address token,
address from,
address to,
address operator,
Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need this as the msg.sender will/should ALWAYS be the operator.

}

// TODO: Revisit
function terminateOperator(address operator) external {
Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

who calls this ?

answer: always the client.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Stebalien Please can you review this function ?

@@ -0,0 +1,612 @@
// SPDX-License-Identifier: UNLICENSED
Copy link
Collaborator Author

@aarshkshah1992 aarshkshah1992 Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write down all the getter APIs. That will determine state design.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to make it as easy as possible to access basic state without an Indexer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strike a balance between on-chain state and off-chain compute than an RPC provider needs to do to serve Getter requests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing the keys of a Hasmap in an a set if you need iteration. Do it only when needed.

}

// TODO: implement
function terminateRail() public {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An operator can always do this by setting everything to 0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a client wants to terminate a rail -> they go the operator and get them to modify the rates and lockups for the rail to 0.

}

struct Rail {
bool isActive;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this to track rail termination !


Rail storage paymentRail = rails[railId];
Account storage payer = accounts[paymentRail.token][paymentRail.from];
Account storage payee = accounts[paymentRail.token][paymentRail.to];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the solidity compiler optimise this so we only lookup the map once ?

settleAccountLockup(payer);

// Determine the maximum allowed epoch we can settle to based on the payer's lockupPeriod.
uint256 maxSettlementEpoch = min(untilEpoch, payer.lockupLastSettledAt + paymentRail.lockupPeriod);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can read payer.lockupLastSettledAt from return value.

uint256 currentPaymentRate = paymentRail.paymentRate;

// Retrieve any queued rate changes for this rail.
RateChangeQueue.Queue storage rateQueue = railRateChangeQueues[railId];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can put the railRateChangeQueues in the Rail itself.

}

// Revert if no progress was made (i.e., the final epoch did not advance).
require(settledUntil > epochStart, "failed to settle: no progress made in settlement");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to support settling nothing to handle partial settlements. E.g., we're going through the queue settling segment by segment and then we hit a segment where we can settle nothing. We need to accept that and settle everything so far without reverting.

uint256 railId,
Rail storage rail,
RateChangeQueue.Queue storage rateQueue,
uint256 initialRate,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the current rail rate. fix naming.

uint256 activeRate = initialRate;
note = "";

while (currentEpoch <= targetEpoch) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentEpoch < targetEpoch is what we need here. if it is equal, we are done and we should exit the loop.

RateChangeQueue.RateChange memory upcomingChange = rateQueue.peek();
bool isWithinRange = (
upcomingChange.untilEpoch >= currentEpoch &&
upcomingChange.untilEpoch <= targetEpoch
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second condition won't be true if I'm trying to settle up to some historical epoch in the past.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to remove this assert.


// Otherwise, we have fully settled up to nextBoundary.
currentEpoch = segmentEndEpoch;
note = arbNote;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be kind of nice to somehow join these notes together when non-empty, but that isn't critical. We mostly care about the last note anyways.

// Dequeue the rate change we just passed (if it was indeed used this round).
if (!rateQueue.isEmpty()) {
// Peek again to confirm it's the one we used.
RateChangeQueue.RateChange memory frontChange = rateQueue.peek();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this not be the case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the queue is non-empty AND next element has expired -> until epoch is in the past or < targetEpoch -> pop.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually -> we can JUST dequeue if we reach here.

upcomingChange.untilEpoch <= targetEpoch
);
require(isWithinRange, "rate queue is in an invalid state");
nextBoundary = upcomingChange.untilEpoch;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min(next, target)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextBoundary = min(nextBoundary, target)

railId,
rail,
currentEpoch,
nextBoundary,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pass min(nextBoundary, targetEpoch)

uint256 activeRate = initialRate;
note = "";

while (currentEpoch <= targetEpoch) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix comparison.

return (settledAmount, settledUntil, note);
}

function _settleWithRateChanges(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mark the function as nonReEntrant.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Infact all functions that modify rail state should have a per-rail lock.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit and REALLY think through it.

* @return settledUntil The final epoch that effectively got settled.
* @return note An optional note returned by the arbitrator.
*/
function _settleSegment(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rentrancy

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per-rail settlement lock

}

// railId => RateChangeQueue
mapping(uint256 => RateChangeQueue.Queue) public railRateChangeQueues;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this to the Rail struct.

@Stebalien
Copy link
Collaborator

The re-entency issue:

We discussed a few approaches:

  1. Lock everything preventing both read and write access to the entire contract while settling a rail. This would restrict access to important information, e.g., information about the rail.
  2. Lock the rail only. Unfortunately, if we lock read access, we'd still be restricting information about the rail. Furthermore, if we don't adjust payee/payer funds while settling and still allow, e.g., the arbiter to see those fields, the arbiter won't see the "full" effects of the last section of payment it arbitrated.

We settled on:

  1. Locking modifications (preventing settling, changing lockup, etc.) to the rail while settling.
  2. Updating the rail, the payee, and the payer after each call to the arbiter such that the arbiter always sees the payment contract in the correct state. Effectively, we move most of the rail settlement logic into the "settle segment" function.

Thinking on this more, there are a few more solutions (which I don't like but I figured I'd mention for completeness).

  1. We could not lock anything, record important state before calling the arbiter, call the arbiter, then revert if "something has changed". This "works" but is strictly more brittle and doesn't provide any benefit.
  2. We could allow payment/lockup changes as long as we're really careful as those changes can only affect future time periods. But I don't think it's worth it and re-entrant modifications are likely always bugs.

@Stebalien
Copy link
Collaborator

We also discussed how this relates to withdrawal. IMO, the difference between this and withdrawal is that all the "withdraw" call can do is revert. It can't cause us to change our minds and withdraw less than we initially intended so there are no funky intermediate states.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants