Skip to content
This repository was archived by the owner on Apr 3, 2026. It is now read-only.

feat: supports EIP-712 and EIP-1271 signatures to claim airdrop #160

Merged
smol-ninja merged 13 commits intostagingfrom
feat/claim-via-sig
Jun 13, 2025
Merged

feat: supports EIP-712 and EIP-1271 signatures to claim airdrop #160
smol-ninja merged 13 commits intostagingfrom
feat/claim-via-sig

Conversation

@smol-ninja
Copy link
Copy Markdown
Member

@smol-ninja smol-ninja commented Jun 2, 2025

Sorry for the massive PR @andreivladbrg. It was very complicated than I thought, both writing source code and then testing it. Now its ready for your review. I will recommend to read the PR description first.

This should be merged after the following PR:


Changelog

Source code

Tests

  • Generates users.recipient using createUserAndKey function as we need private key to create signatures.
  • DRY'ifies some sections in Base test contract and Integration base contract
  • Adds getIndexInMerkleTree and getMerkleProof functions replacing hardcoded use of INDEX1 and index1Proof to avoid manual errors
  • I only added important tests for claimViaSig and ignored what is already being thoroughly tested through claim and claimTo. This is also why I didn't write fuzz tests for it.
  • Refactors users type. See Types.sol for details.

Some notes

  • Foundry currently does not support EIP-712 hashing (refer to Cheatcode: EIP712 canonical hashing foundry-rs/foundry#4818), so its only possible to generate off-chain. I thought of writing TS code to do it but then since that foundry PR is going to get merged soon, we can do hash verification using Foundry cheatcode itself.
  • tests/utils/Utilities.sol contain signature utilities but note that it uses solidity whereas the correct approach is to use eth_signTypedData_v4 so its not being tested. I will wait for foundry to release the new cheatcode for that. Nevertheless, using approach below, I have verified that signature are correctly generated.

To generate EIP-712 signature, run the following in your browser:

Load your address:
const address = (await ethereum.request({ method: 'eth_requestAccounts' }))[0];
Define "TypedData":
const typedData = {
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    Claim: [
      { name: "index", type: "uint256" },
      { name: "recipient", type: "address" },
      { name: "to", type: "address" },
      { name: "amount", type: "uint128" },
    ],
  },
  domain: {
    name: "Sablier Airdrops Protocol",
    chainId: 1, // Chain on which the contract is deployed
    verifyingContract: "0x03cdcCF81765783bE8c15886d0931C816c3A6BC7", // The address of the campaign
  },
  primaryType: "Claim",
  message: {
    index: 2, // The index of the signer in the Merkle tree
    recipient: "0x39EF0Bbb69b81a9EB148530946E94C69012a29C2", // The address of the airdrop recipient
    to: "0xEAF39695973254D21c8956037c0BAf5FdDA92eC6", // The address where recipient wants to transfer the tokens
    amount: "1000000000000000000000" // The amount of tokens allocated to the recipient
  },
}
Call "eth_signTypedData_v4":
ethereum.request({
  method: "eth_signTypedData_v4",
  params: [
    address,
    JSON.stringify(typedData)
  ]
})
.then(signature => {
  console.log("Signature:", signature);
})
.catch(err => {
  console.error(err);
});

It will then show you something like this:

Screenshot 2025-06-02 at 16 59 07

Once you sign it, it will then display the signature in the console.

Some reference:

refactor: adds a shared function that reverts if `to` address is zero
@smol-ninja smol-ninja marked this pull request as draft June 2, 2025 15:39
@smol-ninja smol-ninja changed the title feat: support EIP-712 and EIP-1271 signatures for claiming airdrop feat: support EIP-712 and EIP-1271 signatures to claim airdrop Jun 2, 2025
@smol-ninja smol-ninja force-pushed the feat/claim-via-sig branch from 9cbce83 to 7c415c1 Compare June 2, 2025 16:07
@smol-ninja smol-ninja changed the title feat: support EIP-712 and EIP-1271 signatures to claim airdrop feat: supports EIP-712 and EIP-1271 signatures to claim airdrop Jun 2, 2025
@smol-ninja smol-ninja marked this pull request as ready for review June 3, 2025 21:25

This comment was marked as resolved.

@smol-ninja smol-ninja mentioned this pull request Jun 9, 2025
Copy link
Copy Markdown
Member

@andreivladbrg andreivladbrg left a comment

Choose a reason for hiding this comment

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

Good job on this — it looks like you've put in effort. Overall, it looks good (I've only reviewed the src) , I’ve left a few comments:

Should we add an expiration to the signature? It could be <= campaign expiration.

Comment thread src/libraries/SignatureHash.sol
Comment thread src/libraries/SignatureHash.sol
Comment thread src/interfaces/ISablierMerkleInstant.sol
Comment thread src/abstracts/SablierMerkleBase.sol Outdated
/// @param merkleProof The proof of inclusion in the Merkle tree.
function claimTo(uint256 index, address to, uint128 amount, bytes32[] calldata merkleProof) external payable;

/// @notice Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what if we mention (i) on behalf of eligible recipient (ii) sending it to to address

also: "the airdrop recipient must have signed the message before calling the function"

wdyt?

Copy link
Copy Markdown
Member Author

@smol-ninja smol-ninja Jun 12, 2025

Choose a reason for hiding this comment

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

Claim airdrop on behalf of eligible recipient using an EIP-712 or EIP-1271 signature, and transfer the
tokens to the to address.

Is this good? (01acf19#diff-f6dbd13750414f75f19e46f764de18fcabedac202f2776bf73fb5c39cf83de39)


the airdrop recipient must have signed the message before calling the function

Its already added in the requirement as "If recipient is an EOA, it must match the recovered signer.". To match the recovered signer, it must have signed the msg. Wdyt?

@smol-ninja
Copy link
Copy Markdown
Member Author

Thanks for the review Andrei.

Should we add an expiration to the signature? It

Whats the use case? Since the tx is public, anybody can still execute it. Also, after the campaign is expired, the signature wont work anyways.

@andreivladbrg
Copy link
Copy Markdown
Member

andreivladbrg commented Jun 12, 2025

Whats the use case? Since the tx is public, anybody can still execute it. Also, after the campaign is expired, the signature wont work anyways.

ik that if the campaign is expired no claims are possible, but the idea of having a "forever" signature doesn't sound right

maybe (if its possible) someone reuses it in other contexts of attacks

how i see it is that we minimize the attack surface externally

@andreivladbrg
Copy link
Copy Markdown
Member

re signatures on tests

i remember facing lots of issues when i integrated permit2 in the first versions of the protocol back 2 years ago

don't know if it will help, but will leave it here: https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/test/utils/PermitSignature.sol

@smol-ninja
Copy link
Copy Markdown
Member Author

but the idea of having a "forever" signature doesn't sound right

I can't comment on "sounding right" but technically speaking, signature is useless anyway after campaign expiry. Also, it has replay protection so the same signature cannot be used on another chain or another campaign. I am happy to implement it if there is any UX advantage which I am not seeing right now.

how i see it is that we minimize the attack surface externally

But no attacks are possible because (1) each campaign has different address (2) chain ID is calculated in the constructor for hash

The only attack is in case of a fork but in that case, the campaign will also be forked and all eligibility will be valid on both the chains.

@smol-ninja smol-ninja force-pushed the feat/claim-via-sig branch from 01acf19 to 0173076 Compare June 13, 2025 10:26
Copy link
Copy Markdown
Member

@andreivladbrg andreivladbrg left a comment

Choose a reason for hiding this comment

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

re the signature deadline, yeah, it's not worth it, the current version is safe

LGTM

@smol-ninja smol-ninja merged commit 72b6aae into staging Jun 13, 2025
7 checks passed
@smol-ninja smol-ninja deleted the feat/claim-via-sig branch June 13, 2025 12:57
smol-ninja added a commit that referenced this pull request Oct 8, 2025
* docs: update natspecs for claim functions

* feat: adds claim-via-signature function
refactor: adds a shared function that reverts if `to` address is zero

* refactor: moves domain type hash inside constructor

* tests: adds tests for claim via signature

* docs: fix domain name

* chore: adds AI recommendations

* chore: update evm-utils version

* test: default recipient functions

* refactor: add notZeroAddress modifier

* build: bump packages

* chore: remove redundant code

* docs: update contributing file

* docs: polish natpsec

---------

Co-authored-by: Andrei Vlad Birgaoanu <andreivladbrg@gmail.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants