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

Version 0 Review PR #29

Open
wants to merge 3 commits into
base: Version-0-Review
Choose a base branch
from
Open

Conversation

0age
Copy link
Collaborator

@0age 0age commented Nov 7, 2024

This PR is meant to serve as a hub for review comments on Version 0.

@0age 0age changed the title add files back Version 0 Review PR Nov 7, 2024
using SafeTransferLib for address;

// Storage slot seed for ERC6909 state, used in computing balance slots.
uint256 private constant _ERC6909_MASTER_SLOT_SEED = 0xedcaa89a82293940;

Choose a reason for hiding this comment

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

what is this value? where did it come from?

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 from solady's ERC6909 implementation, since the _release and _withdraw functions use the same general pathways as the standard transfers (but skip allowance checks and transfer hooks)

src/lib/ClaimProcessorFunctionCastLib.sol Outdated Show resolved Hide resolved
* @param fnIn Function pointer to `ClaimProcessorLib.processSimpleClaim`.
* @return fnOut Modified function used in `ClaimProcessorLogic._processBasicClaim`.
*/
function usingBasicClaim(function(bytes32, uint256, uint256, bytes32, bytes32, function(address, address, uint256, uint256) internal returns (bool)) internal returns (bool) fnIn)

Choose a reason for hiding this comment

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

Can you explain at a high level why this is necessary? I've read the natspec but still not sure. Which one is the correct function type for the function thats going to be called? Why would the type ever be incorrect and need changing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, using this function as an example:

  • ClaimProcessorLib has a function processSimpleClaim that is used as a shared entrypoint for processing four different claim types (basic claim, claim with witness, multichain claim, and multichain claim with witness) where the only key differences are how the claim hash is derived and what the calldata offset to the relevant data is. To facilitate this, the processSimpleClaim function takes a uint256 calldataPointer argument instead of like a BasicClaim calldata argument.
  • When we're actually calling processSimpleClaim inside of ClaimProcessorLogic._processBasicClaim, the claimPayload has a BasicClaim calldata claimPayload type. While you could do a typecast to a uint256 inside of that function before calling processSimpleClaim, it uses extra gas and critically takes up more contract size, so instead we can use a function cast as a "zero-cost abstraction" to accomplish the same thing
  • The fnIn function matches the arguments that processSimpleClaim expects, but the fnOut function matches the argument types that are actually being provided

Comment on lines +4 to +16
// Message signed by the sponsor that specifies the conditions under which their
// tokens can be claimed; the specified arbiter verifies that those conditions
// have been met and specifies a set of beneficiaries that will receive up to the
// specified amount of tokens.
struct Compact {
address arbiter; // The account tasked with verifying and submitting the claim.
address sponsor; // The account to source the tokens from.
uint256 nonce; // A parameter to enforce replay protection, scoped to allocator.
uint256 expires; // The time at which the claim expires.
uint256 id; // The token ID of the ERC6909 token to allocate.
uint256 amount; // The amount of ERC6909 tokens to allocate.
// Optional witness may follow.
}
Copy link

@yorhodes yorhodes Nov 13, 2024

Choose a reason for hiding this comment

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

do you expect that the "conditions under which their tokens can be claimed" to be encapsulated in optional witness data or in the address of the arbiter?

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 think generally the conditions would be specified in the witness data which is in turn leveraged by the arbiter; that way the arbiter can be more general-purpose!

@vimageDE vimageDE self-requested a review November 19, 2024 16:12
* formation and mediation of reusable "resource locks."
* This contract has not yet been properly tested, audited, or reviewed.
*/
contract TheCompact is ITheCompact, ERC6909, TheCompactLogic {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Lets notify readers of the NatSpec for the functions in the interface

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah an InheritDoc declaration is a good call to signal that to both humans and machines

_registerWithDefaults(claimHash, typehash);
}

function deposit(address token, address allocator, uint256 amount) external returns (uint256) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could also be used for native tokens to reduce numbers of functions.
amount could be used to verify the intended amount of native tokens to be deposited

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 seem nice to have a very simple interface for depositing native tokens where we can infer the amount from msg.value, but yes this is an option!

Copy link
Collaborator

Choose a reason for hiding this comment

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

I usually agree, the reason why I am hesitant in this case is because we already have this big amount of deposit functions. For the sake of keeping the interface for the compact as simple as possible, I feel like this could be one of the ways to do so easily. Very much dependent on the amount of deposit functions in the final product of course.

_registerWithDefaults(claimHash, typehash);
}

function deposit(address allocator, ResetPeriod resetPeriod, Scope scope, address recipient) external payable returns (uint256) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

General thoughts on the deposit functions:

  • Having the recipient as a parameter (as we do here) allows us to potentially outsource depositAndRegister functions to helper contracts.
  • The contract feels a little like uniswap core and periphery contracts were combined in one, since it offers so much. Could be interesting to explore where to separate logic to keep it easy to read and modular at the same time.
  • In the case of splitting the logic, we could use a callback for the router contract to send in the tokens (like in uniswap V3), to save gas by skipping approval for this contract.

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 issue with this is that the caller can't (safely) register on behalf of the recipient; effectively since the only valid sponsor is msg.sender unless they sign for it like with permit2, you're not able to spend the deposited tokens as part of the registration

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, that is indeed an issue.

But I wonder if we should think about this in a different way:
In theory, the sponsor (whoever is paying for the deposit) could be trusted to also be able to register what he has sponsored for directly. The receiver (while via a router also the original sponsor), needs to trust the router with his tokens to do the right thing anyway (depositing it), so it might be fair enough to fully trust the sponsor and see the recipient as a 'receiver' of the finished deposit (and maybe already registered) token deposit.

Indeed it gives the sponsor full responsibility with the process of registering. But he is trusted with full control over the users tokens in the first place anyway.

}

function deposit(address token, address allocator, ResetPeriod resetPeriod, Scope scope, uint256 amount, address recipient) external returns (uint256) {
return _performCustomERC20Deposit(token, allocator, resetPeriod, scope, amount, recipient);
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could eliminate a lot of the functions in the deposit contracts by handling the default parameters in this contract. When first reading through it, it felt like this would also increase readability, because it would be easy to see, that the deposit function is the same as before, just with real input params, instead of the default.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah we should prototype a router contract and compare gas usage! big kicker is persisting the depositor as "authing" the deposit; one big simplification would be to only have the Permit2 deposit methods for tokens which would let you use a router at will but of course that implies a hard dependency on Permit2 which may not be ideal in all cases

Copy link
Collaborator

Choose a reason for hiding this comment

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

I understand what you mean, but I think this was a misunderstanding of this specific comment:
In this case I was more speaking about moving the default data, like the default scope and default resetPeriod into this contract, rather then having it applied in the DirectDepositLogic.sol.
This would mean we could use the same internal deposit function in DirectDepositLogic.sol for both external deposit functions which I think would increase readability.

return _performCustomERC20Deposit(token, allocator, resetPeriod, scope, amount, recipient);
}

function deposit(uint256[2][] calldata idsAndAmounts, address recipient) external payable returns (bool) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

In here, we actually combine native and ERC20 tokens in the same function, so I feel like we can do the same in previous functions.

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 idea here is that you might want to simultaneously perform a native token + erc20 token deposit, but on the single case it's always one or the other

Copy link
Collaborator

Choose a reason for hiding this comment

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

I understand where the decision is coming from, I was more focused on the part of having the msg.value being verified via the amount input as a backup to confirm the sponsors deposit decision.

y := add(y, shr(32, y))

// Look up final value in the sequence.
compactFlag := and(shr(and(sub(72, and(y, 127)), not(3)), 0xfedcba9876543210000), 15)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we add some sort of mathematical proof to show, that the last 88 bits of an address + the compactFlag is sufficient to be fully unique?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

would love that (assuming you mean as a comment or a test, since this is getting evaluated frequently)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, that is what I mean

let expires := add(timestamp(), duration)

// Ensure new expiration does not exceed current and duration does not exceed 30 days.
if or(lt(expires, sload(cutoffSlot)), gt(duration, 0x278d00)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we move the hardcoded max of 30 days expiration (0x278d00) to a constant for easy adaptation and readability?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah that's a great idea, named constants FTW

// Compute new expiration based on current timestamp and supplied duration.
let expires := add(timestamp(), duration)

// Ensure new expiration does not exceed current and duration does not exceed 30 days.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should probably be:
// Ensure new expiration is not earlier than current and duration does not exceed 30 days.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good catch!

@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could help to add a comment to ensure this is enum is not getting changed by mistake during development:
// This enum cannot be increased. Else, the first bit will be cut off in the token id

Choose a reason for hiding this comment

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

a bit confused why you would use an enum to represent a duration in the first place

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 the logic behind using an enum rather than just an arbitrary value (which was the original design) is twofold:

  • having the capability to pack all the parameters of the resource lock into the ID is a big win for indexing, and so only taking up 3 bits for the reset period leaves more room for encoding the allocator ID
  • standardizing on a set of common reset periods will promote composability so there's a greater chance that a given lock can be transferred directly rather than needing to be withdrawn and redeposited into a new lock

@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could help to add a comment to ensure this is enum is not getting changed by mistake during development:
// This enum cannot be increased. Else, the first bit will be cut off in the token id

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah maybe with a //@dev tag

// specified amount of tokens.
struct Compact {
address arbiter; // The account tasked with verifying and submitting the claim.
address sponsor; // The account to source the tokens from.

Choose a reason for hiding this comment

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

why does this commit to the sponsor address if the sponsor is the signer?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

a few reasons:

  • the allocator also signs the payload, so that way one payload can't be replayed for multiple sponsors
  • by including this in the payload, the resultant claim hash can uniquely identify the compact in question
  • you can run validation on the payload without the final signature (say a filler wants to provide a quote and wants to ensure the balance is available, but the allocator doesn't want to leak the full signed payload prematurely)

uint256 id; // The token ID of the ERC6909 token to allocate.
uint256 allocatedAmount; // The original allocated amount of ERC6909 tokens.
address claimant; // The claim recipient; specified by the arbiter.
uint256 amount; // The claimed token amount; specified by the arbiter.

Choose a reason for hiding this comment

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

something about <= allocatedAmount would be helpful
my mental model for this is fee

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

maybe originalAmount or maxAmount would be clearer? I definitely want to avoid fee as it has specific connotations that might not be applicable, but it's a good mental model for many applications

Comment on lines +24 to +25
bytes allocatorSignature; // Authorization from the allocator.
bytes sponsorSignature; // Authorization from the sponsor.

Choose a reason for hiding this comment

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

find it unintuitive that dynamic length fields are at the beginning (rather than end)

Choose a reason for hiding this comment

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

I guess they have a known length but ABI encoder doesnt know that

Choose a reason for hiding this comment

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

could you type these to bytes32[2] instead? I see you are using vm.signCompact (https://eips.ethereum.org/EIPS/eip-2098) in tests but not sure if you want to assume all wallets use 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.

they're at the beginning so that the pointers are always present at specific calldata offsets, regardless of the struct; agree that the ordering is a bit weird, but it's also never directly exposed to end users

there are definitely cases where you can only provide 65 byte signatures, plus we want to support 1271 schemes that might not even pass in an ECDSA signature or need extra data

uint256 amount; // The claimed token amount; specified by the arbiter.
}

struct ClaimWithWitness {

Choose a reason for hiding this comment

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

imo it would make these a lot more readable if they used nested structs for the shared fields which I believe should have equivalent memory layout

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

they have equivalent calldata layouts i think, but memory layout is definitely less compact; main "win" is when comparing across multiple claim endpoints, I'm guessing? when considering a single endpoint in isolation it's kind of a wash

I'm quite bullish on using some kind of custom ABI encoding scheme for V1, though maybe the benefits outweigh the complexity since they're internal calls being performed anyway... 🤔

struct Compact {
address arbiter; // The account tasked with verifying and submitting the claim.
address sponsor; // The account to source the tokens from.
uint256 nonce; // A parameter to enforce replay protection, scoped to allocator.

Choose a reason for hiding this comment

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

what is the sponsor's view into this if its scoped to the allocator? seems hard for clients to avoid nonce collisions

Choose a reason for hiding this comment

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

or is it scoped to the allocator and the sponsor?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it's just scoped to the allocator, but a pretty simple / easy to enforce additional scoping scheme that allocators could implement is "first 20 bytes of the nonce must equal the sponsor"

@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

Choose a reason for hiding this comment

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

a bit confused why you would use an enum to represent a duration in the first place

Comment on lines +4 to +7
enum Scope {
Multichain,
ChainSpecific
}

Choose a reason for hiding this comment

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

why is this important to distinguish? the arbiter implicitly commits to 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.

ah, this actually doesn't refer to spending the tokens to receive tokens back on another chain; this is enabling MultichainCompact messages signed on other domains to spend the tokens on the current domain. So like if I had a resource lock on mainnet with Scope.Multichain then I could sign a MultichainCompact against Optimism that incudes a segment with mainnet (1) as the chainId that resource lock and submit it on mainnet to claim the tokens in the resource lock on mainnet, whereas if it's chain-specific I can't use it like that. It's basically just an optional added safety feature if you're on your "home chain"

* @notice Claim endpoints can only be called by the arbiter indicated on the associated
* compact, and are used to settle the compact in question. There are 96 endpoints in total,
* based on all the various possible combinations of a number of factors:
* - transfer vs. withdrawal: whether to transfer the claimed ERC6909 tokens directly, or to

Choose a reason for hiding this comment

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

this dimension doubles the number of endpoints (for sponsor clients and arbiters)
is it that unreasonable to require the claimant be ERC6909 compatible?

Copy link

@yorhodes yorhodes Nov 20, 2024

Choose a reason for hiding this comment

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

oh I guess 6909 does not define a deposit/withdraw interface...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah it does mean more endpoints, though arbiters could also just opt not to use claimAndWithdraw (or of course they could only use claimAndWithdraw if they preferred)

Sponsor clients don't ever interact with these endpoints fwiw, only with allocatedTransfer and allocatedWithdrawal (which does mean double the endpoints but sponsors would almost certainly want to be able to do both)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

you can use the default 6909 transfer methods but there's no way to pass in a signature from the allocator or other context as part of the call, so you need to leverage the attest callback on the allocator for it

return _depositBatchAndRegisterViaPermit2(depositor, permitted, resetPeriod, claimHash, compactCategory, witness, signature);
}

function allocatedTransfer(BasicTransfer calldata transfer) external returns (bool) {
Copy link
Collaborator

@vimageDE vimageDE Nov 25, 2024

Choose a reason for hiding this comment

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

The functions allocatedTransfer as well as allocatedWithdrawal always requires the msg.sender to be the sponsor. Why do we not add a from address as an input and make use of the granular approval system of ERC 6909?
So either the msg.sender is the sponsor directly, or the msg.sender has previously received a sufficient token approval from the sponsor. This would provide flexibility and enable a lot of use cases with the ERC6909 tokens, while approval would also be a well recognized and still simple system to handle this.

The signature / ERC 1271 approval of the allocator would of course still be required.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

left a note in "Path To V1" section of the README to explore this idea: a5f6b0a

return _getForcedWithdrawalStatus(account, id);
}

function getLockDetails(uint256 id) external view returns (address, address, ResetPeriod, Scope) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we name the output of the function to make it very clear what address is the token and which is the allocator?

library ConsumerLib {
// Storage scope identifiers for nonce buckets.
uint256 private constant _ALLOCATOR_NONCE_SCOPE = 0x03f37b1a;
uint256 private constant _SPONSOR_NONCE_SCOPE = 0x8ccd9613;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we make it clear where both of these are coming from?

// keccak256(_CONSUMER_NONCE_SCOPE ++ account ++ nonce[0:31])
mstore(0x20, account)
mstore(0x0c, scope)
mstore(0x40, nonce)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could safely use the first 56 bytes (instead of leaving them empty) , this way we do not have to store and rewrite the free memory pointer, because we would not override it.

using SafeTransferLib for address;

// Storage slot seed for ERC6909 state, used in computing balance slots.
uint256 private constant _ERC6909_MASTER_SLOT_SEED = 0xedcaa89a82293940;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We currently have this exact seed stored in multiple places (DepositLogic.sol & SharedLogic.sol), lets stick to one source instead?

mstore(add(categorySpecificStart, 0x40), PERMIT2_ACTIVATION_MULTICHAIN_COMPACT_TYPESTRING_FRAGMENT_THREE)
mstore(add(categorySpecificStart, 0x60), PERMIT2_ACTIVATION_MULTICHAIN_COMPACT_TYPESTRING_FRAGMENT_FOUR)
mstore(add(categorySpecificStart, 0x70), PERMIT2_ACTIVATION_MULTICHAIN_COMPACT_TYPESTRING_FRAGMENT_SIX)
mstore(add(categorySpecificStart, 0x60), PERMIT2_ACTIVATION_MULTICHAIN_COMPACT_TYPESTRING_FRAGMENT_FIVE)
Copy link
Collaborator

Choose a reason for hiding this comment

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

POSSIBLE BUG:
FRAGMENT_FIVE needs to be starting at 0x80 to not override FRAGMENT_FOUR. And FRAGMENT_SIX needs therefor to be starting at 0x90

mstore(add(categorySpecificStart, 0x60), PERMIT2_ACTIVATION_MULTICHAIN_COMPACT_TYPESTRING_FRAGMENT_FIVE)

// Set memory pointers for Activation and Category-specific data end.
categorySpecificEnd := add(categorySpecificStart, 0x90)
Copy link
Collaborator

Choose a reason for hiding this comment

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

POSSIBLE BUG:
The end is based on the (likely wrong) previous values. When those are fixed,
FRAGMENT_SIX would actually be ending at categorySpecificStart + 0xb0, not 0x90


// Insert tokenPermissions typestring fragment.
let tokenPermissionsFragmentStart := add(categorySpecificEnd, witnessLength)
mstore(add(tokenPermissionsFragmentStart, 0x0e), TOKEN_PERMISSIONS_TYPESTRING_FRAGMENT_TWO)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This will override the last byte of the witness data, which is also supposed to be a ) (closing bracket).
After talking with @0age about this, a solution for version 1 would be to limit the witness data to the input of the struct.
Example of a CURRENTLY expected witness:

Witness witness)Witness(uint256 witnessArgument)

The NEW expected witness data would look like this:

uint256 witnessArgument

The new approach will be clearer for developers and lead to smaller calldata. It also ensures the requirements of EIP-712, that all struct definitions need to be in alphanumerical order in the typestring (as long as the Witness does not contain additional structs). This makes the approach less prone to errors for other developers.

* @notice Internal function for depositing ERC20 tokens using Permit2 authorization. The
* depositor must approve Permit2 to transfer the tokens on its behalf unless the token in
* question automatically grants approval to Permit2. The ERC6909 token amount received by the
* by the recipient is derived from the difference between the starting and ending balance held
Copy link
Collaborator

Choose a reason for hiding this comment

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

2x by the

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.

4 participants