Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
"src/swarms/doc/iso3166-2"
],
"ignoreWords": [
"AMPL",
"NODL",
"Nodle",
"Typehashes",
"depin",
"contentsign",
"matterlabs",
Expand Down
314 changes: 150 additions & 164 deletions src/envelope/EnvelopeLinks.sol

Large diffs are not rendered by default.

52 changes: 46 additions & 6 deletions src/envelope/doc/EnvelopeLinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,53 @@ Gasless eligibility is independent of the gift amount. The paymaster must still
constructor(address mfaAuthorizer, address owner, address feeToken)
```

| Param | Purpose |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. |
| `owner` | Owns the vault and can withdraw accumulated fees. |
| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. |
| Param | Purpose |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. Rotatable by owner via `setMfaAuthorizer`. |
| `owner` | Owns the vault, can withdraw accumulated fees, and rotate the `mfaAuthorizer`. |
| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. |

The constructor also sets the EIP-712 domain separator used by the vault-side validation helpers.
## Owner Functions

| Function | Purpose |
| ------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `setMfaAuthorizer(address)` | Rotate the MFA/fee-authorization signer. Invalidates all in-flight signatures from the old key. |
| `withdrawFees(address tokenAddress)` | Withdraw accumulated service and gasless fees for a given token. |

## Security Properties

### Fee-On-Transfer Token Safety

For ERC-20 deposits, the vault measures the actual `balanceOf` delta rather than trusting the requested `amount`. This prevents insolvency when fee-on-transfer or rebasing tokens are deposited. The recorded `link.asset.amount` reflects what the vault actually received and can transfer back.

For raffle-style links (which have per-link variable amounts), a fee-on-transfer token will cause the deposit to revert because the vault asserts the received total matches the requested total.

### Fee Authorization Replay Protection

Each `FeeAuthorization` signature can only be used once. The vault tracks consumed authorizations via `usedFeeAuthorizations[keccak256(signature)]` and reverts with `FeeAuthorizationAlreadyUsed` on replay attempts.

### Recipient Validation

- Claims to `address(0)` are rejected with `ZeroRecipientAddress`.
- `claimAsBoundRecipient` reverts with `LinkNotRecipientBound` if the link has no stored recipient, preventing misuse of the bound-mode signature on open links.

### MFA Authorizer Rotation

The `mfaAuthorizer` is mutable (not immutable). In case of backend key compromise, the owner can rotate the signer immediately via `setMfaAuthorizer`. All in-flight MFA and fee authorization signatures from the old key become invalid after rotation.

### Unsupported Token Types

The following token types are **not supported** and should not be deposited:

- **Rebasing tokens** (e.g., stETH, AMPL): balance changes between deposit and claim may cause under/overpayment.
- **Tokens with transfer hooks that modify balances** beyond a simple fee deduction.
- **ERC-777 tokens**: the vault does not implement `tokensReceived` and relies on `nonReentrant` guards.

ERC-20 tokens that charge a fixed transfer fee (e.g., USDT on some chains) are supported — the vault records the actual received amount.

### View-Only Functions (Off-Chain Only)

`getLinkIndexesCreatedBy` and `getAllLinkIndexes` iterate over the entire links array. These are O(n) and intended for off-chain use only. On-chain callers will encounter out-of-gas for large link counts.

## Deposit Model

Expand Down
4 changes: 3 additions & 1 deletion src/envelope/doc/EnvelopePaymaster.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ The paymaster supports ZkSync general flow only.
5. It verifies it has enough ETH for `requiredETH`.
6. `BasePaymaster` pays the bootloader.

The paymaster does not keep per-gift state and does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeLinks` at deposit creation.
The paymaster does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeLinks` at deposit creation.

The paymaster records one validation attempt per link before paying the bootloader. ZkSync runs validation and execution separately, so this attempt remains recorded even when the subsequent vault execution reverts. Up to `MAX_GASLESS_ATTEMPTS_PER_LINK` (currently **3**) attempts are allowed per link. This gives users room for honest retries (e.g. wrong gas limit, receiver contract not yet deployed) while bounding paymaster loss from repeated execution failures. Once the limit is reached, the user can still submit the vault call while paying gas themselves.

## Sponsored Selectors

Expand Down
6 changes: 3 additions & 3 deletions src/envelope/doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga

## Deploy

| Script | Purpose |
| ---------------------------------- | ----------------------------------------------------------- |
| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. |
| Script | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. |
| `script/DeployEnvelopeZkSync.s.sol` | Forge deployment script for `EnvelopeLinks` and optional `EnvelopePaymaster` on ZkSync Era. |

Important environment variables:
Expand Down
34 changes: 33 additions & 1 deletion src/paymasters/EnvelopePaymaster.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@ import {IEnvelopeGaslessValidator} from "../envelope/IEnvelopeGaslessValidator.s
/// @dev The EnvelopeLinks remains the source of truth for whether a call is valid and prepaid or sponsored.
/// This paymaster only accepts general-flow transactions targeting that vault.
contract EnvelopePaymaster is BasePaymaster {
uint256 public constant MAX_GASLESS_ATTEMPTS_PER_LINK = 3;

IEnvelopeGaslessValidator public immutable envelopeLinks;

mapping(uint256 => uint256) public gaslessAttemptsByLink;

error DestinationIsNotEnvelopeLinks();
error EnvelopeGaslessOperationNotApproved();
error PaymasterBalanceTooLow();
error GaslessAttemptLimitReached(uint256 index);

event GaslessAttemptRecorded(uint256 indexed index, uint256 indexed attempts);

constructor(address admin, address withdrawer, address envelopeLinks_) BasePaymaster(admin, withdrawer) {
envelopeLinks = IEnvelopeGaslessValidator(envelopeLinks_);
}

function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory transactionData)
internal
view
override
{
if (to != address(envelopeLinks)) revert DestinationIsNotEnvelopeLinks();
Expand All @@ -35,6 +41,32 @@ contract EnvelopePaymaster is BasePaymaster {
if (!approved) revert EnvelopeGaslessOperationNotApproved();

if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow();

_recordGaslessAttempt(transactionData);
}

function _recordGaslessAttempt(bytes memory transactionData) internal {
uint256 index = _decodeGaslessLinkIndex(transactionData);
uint256 attempts = gaslessAttemptsByLink[index];
if (attempts == MAX_GASLESS_ATTEMPTS_PER_LINK) revert GaslessAttemptLimitReached(index);

unchecked {
++attempts;
}
gaslessAttemptsByLink[index] = attempts;
emit GaslessAttemptRecorded(index, attempts);
}

/// @dev Reads the first uint256 calldata argument out of `transactionData` (the link index).
/// Safe because this is only called after `isValidGaslessOperation` matched one of the
/// claim/reclaim selectors, all of which have `uint256 _index` as their first parameter.
/// Offset 36 = 32 (bytes-memory length prefix) + 4 (function selector).
function _decodeGaslessLinkIndex(bytes memory transactionData) internal pure returns (uint256 index) {
if (transactionData.length < 36) revert EnvelopeGaslessOperationNotApproved();

assembly {
index := mload(add(transactionData, 36))
}
}

function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256)
Expand Down
Loading
Loading