diff --git a/.claude/settings.json b/.claude/settings.json index ac63be65a..2b0708614 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,159 +1,410 @@ { "permissions": { "allow": [ - // ========== WEB FETCH ========== - "WebFetch(*)", - - // ========== FILE OPERATIONS (read-only & safe writes) ========== - "Bash(ls:*)", - "Bash(pwd:*)", - "Bash(cat:*)", - "Bash(mkdir:*)", - "Bash(cp:*)", - "Bash(mv:*)", - "Bash(find:*)", - "Bash(grep:*)", - "Bash(sed:*)", - "Bash(awk:*)", - "Bash(sort:*)", - "Bash(uniq:*)", - "Bash(wc:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(touch:*)", - "Bash(tree:*)", - "Bash(du:*)", - "Bash(df:*)", - - // ========== NODE.JS / PACKAGE MANAGEMENT ========== - "Bash(node:*)", - "Bash(npm:*)", - "Bash(npx:*)", - "Bash(yarn:*)", - "Bash(pnpm:*)", - - // ========== TYPESCRIPT / BUILD ========== - "Bash(tsc:*)", - "Bash(ts-node:*)", - - // ========== TESTING ========== - "Bash(jest:*)", - - // ========== LINTING / FORMATTING ========== - "Bash(eslint:*)", - "Bash(prettier:*)", - - // ========== SAFE GIT OPERATIONS ========== - "Bash(git status:*)", - "Bash(git log:*)", - "Bash(git diff:*)", - "Bash(git show:*)", - "Bash(git branch:*)", - "Bash(git stash list:*)", - "Bash(git tag:*)", - "Bash(git remote -v:*)", - "Bash(git fetch:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git checkout:*)", - "Bash(git merge:*)", - "Bash(git rebase:*)", - - // ========== TEXT PROCESSING & UTILITIES ========== - "Bash(echo:*)", - "Bash(printf:*)", - "Bash(tr:*)", - "Bash(cut:*)", - "Bash(xargs:*)", - "Bash(jq:*)", - "Bash(base64:*)", - - // ========== PROCESS INFO (read-only) ========== - "Bash(ps:*)", - "Bash(whoami:*)", - "Bash(which:*)", - "Bash(where:*)", - - // ========== MISC UTILITIES ========== - "Bash(date:*)", - "Bash(sleep:*)", - "Bash(test:*)", - "Bash(zip:*)", - "Bash(unzip:*)" + "Bash(git status*)", + "Bash(git add *)", + "Bash(git add .*)", + "Bash(git add -A*)", + "Bash(git add -u*)", + "Bash(git commit -m *)", + "Bash(git commit -am *)", + "Bash(git push*)", + "Bash(git push origin *)", + "Bash(git push -u origin *)", + "Bash(git pull*)", + "Bash(git pull origin *)", + "Bash(git fetch*)", + "Bash(git fetch --all*)", + "Bash(git fetch --prune*)", + "Bash(git log*)", + "Bash(git log --oneline*)", + "Bash(git log --graph*)", + "Bash(git diff*)", + "Bash(git diff --staged*)", + "Bash(git diff HEAD*)", + "Bash(git checkout -b *)", + "Bash(git branch*)", + "Bash(git branch -a*)", + "Bash(git branch -d *)", + "Bash(git merge *)", + "Bash(git stash pop*)", + "Bash(git stash list*)", + "Bash(git remote *)", + "Bash(git remote -v*)", + "Bash(git remote show origin*)", + "Bash(git tag *)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git clone *)", + "Bash(git init*)", + "Bash(git ls-remote *)", + "Bash(git gc*)", + "Bash(git reflog*)", + "Bash(ls*)", + "Bash(ls -la*)", + "Bash(ls -l*)", + "Bash(ls -a*)", + "Bash(ls -lh*)", + "Bash(ls -la */)", + "Bash(cat *)", + "Bash(cat -n *)", + "Bash(less *)", + "Bash(head *)", + "Bash(head -n *)", + "Bash(head -*)", + "Bash(tail *)", + "Bash(tail -n *)", + "Bash(tail -*)", + "Bash(tail -f *)", + "Bash(grep *)", + "Bash(grep -r *)", + "Bash(grep -i *)", + "Bash(grep -n *)", + "Bash(grep -E *)", + "Bash(grep -v *)", + "Bash(sed *)", + "Bash(sed -n *)", + "Bash(sed -e *)", + "Bash(awk *)", + "Bash(cut *)", + "Bash(sort *)", + "Bash(uniq *)", + "Bash(wc *)", + "Bash(wc -l *)", + "Bash(find *)", + "Bash(find . -name *)", + "Bash(find . -type *)", + "Bash(find . -path *)", + "Bash(cp *)", + "Bash(cp -r *)", + "Bash(cp -a *)", + "Bash(cp -p *)", + "Bash(mv *)", + "Bash(mkdir *)", + "Bash(mkdir -p *)", + "Bash(rmdir *)", + "Bash(touch *)", + "Bash(diff *)", + "Bash(tree *)", + "Bash(pwd*)", + "Bash(cd *)", + "Bash(basename *)", + "Bash(dirname *)", + "Bash(realpath *)", + "Bash(readlink *)", + "Bash(stat *)", + "Bash(file *)", + "Bash(file -b *)", + "Bash(node *)", + "Bash(node -v*)", + "Bash(node --version*)", + "Bash(node scripts/*)", + "Bash(node *.js*)", + "Bash(pnpm *)", + "Bash(pnpm install*)", + "Bash(pnpm add *)", + "Bash(pnpm run *)", + "Bash(pnpm build*)", + "Bash(pnpm test*)", + "Bash(pnpm list*)", + "Bash(pnpm --filter *)", + "Bash(nvm *)", + "Bash(nvm install *)", + "Bash(nvm use *)", + "Bash(nvm ls*)", + "Bash(nvm current*)", + "Bash(jq *)", + "Bash(jq .*)", + "Bash(jq -r *)", + "Bash(jq -c *)", + "Bash(jq -s *)", + "Bash(* | jq)", + "Bash(* | jq .)", + "Bash(* | jq -r *)", + "Bash(* | jq '.*')", + "Bash(* | jq \".*\")", + "Bash(tar *)", + "Bash(tar -czf *)", + "Bash(tar -xzf *)", + "Bash(tar -tvf *)", + "Bash(tar --exclude=* *)", + "Bash(zip *)", + "Bash(zip -r *)", + "Bash(zip -u *)", + "Bash(zip -d *)", + "Bash(unzip *)", + "Bash(unzip -l *)", + "Bash(unzip -o *)", + "Bash(unzip -d * *)", + "Bash(gzip *)", + "Bash(gzip -d *)", + "Bash(gunzip *)", + "Bash(echo $*)", + "Bash(echo \"$*\")", + "Bash(echo '$*')", + "Bash(echo *)", + "Bash(export *)", + "Bash(unset *)", + "Bash(env*)", + "Bash(env | grep*)", + "Bash(printenv*)", + "Bash(printenv | grep*)", + "Bash(source *)", + "Bash(. *)", + "Bash(cp .env.example .env*)", + "Bash(cp .env .env.backup*)", + "Bash(ps *)", + "Bash(ps aux*)", + "Bash(ps -ef*)", + "Bash(ps | grep*)", + "Bash(pgrep *)", + "Bash(kill *)", + "Bash(kill -9 *)", + "Bash(pkill *)", + "Bash(jobs*)", + "Bash(sleep *)", + "Bash(wait*)", + "Bash(lsof -i :*)", + "Bash(lsof -i *)", + "Bash(pidof *)", + "Bash(date*)", + "Bash(date +*)", + "Bash(whoami*)", + "Bash(hostname*)", + "Bash(uname *)", + "Bash(which *)", + "Bash(whereis *)", + "Bash(df -h*)", + "Bash(du -sh *)", + "Bash(uptime*)", + "Bash(ping *)", + "Bash(netstat *)", + "Bash(ss *)", + "Bash(lsof *)", + "Bash(nslookup *)", + "Bash(dig *)", + "Bash(netstat -an | grep *)", + "Bash(ss -an | grep *)", + "Bash(nc -zv * *)", + "Bash(openssl rand -hex 32*)", + "Bash(openssl rand -base64 *)", + "Bash(openssl rand *)", + "Bash(gh *)", + "Bash(gh auth *)", + "Bash(gh auth status*)", + "Bash(gh repo *)", + "Bash(gh repo view*)", + "Bash(gh pr *)", + "Bash(gh pr create*)", + "Bash(gh pr list*)", + "Bash(gh pr view*)", + "Bash(gh issue *)", + "Bash(gh api *)", + "Bash(gh workflow *)", + "Bash(gh release *)", + "Bash(./*.sh*)", + "Bash(./scripts/*)", + "Bash(bash scripts/*)", + "Bash(sh scripts/*)", + "Bash(tr *)", + "Bash(tee *)", + "Bash(xargs *)", + "Bash(printf *)", + "Bash(echo * | tr *)", + "Bash(echo * | sed *)", + "Bash(echo * | awk *)", + "Bash(echo * | base64*)", + "Bash(echo * | base64 -d*)", + "Bash(md5sum *)", + "Bash(sha256sum *)", + "Bash(shasum *)", + "Bash(uuidgen*)", + "Bash(tail -f logs/*)", + "Bash(tail -f *.log*)", + "Bash(grep -i error logs/*)", + "Bash(grep -i warn logs/*)", + "Read(**)", + "Edit(./**)", + "MultiEdit(./**)", + "Write(./**)", + "Glob(**)", + "Grep(**)", + "LS(**)", + "WebFetch(domain:docs.anthropic.com)", + "WebFetch(domain:localhost)", + "WebFetch(domain:127.0.0.1)", + "WebFetch(domain:github.com)", + "WebFetch(domain:api.anthropic.com)", + "TodoWrite(**)", + "Bash(npx vitest *)", + "Bash(git mv *)", + "Bash(git rm *)", + "Bash(npx --no-install tsc --noEmit -p tsconfig.json)", + "Bash(npx tsc *)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api.github.com)" ], - "deny": [ - // ========== DESTRUCTIVE FILESYSTEM OPERATIONS ========== + "Bash(rm -rf /*)", "Bash(rm -rf /)", - "Bash(rm -rf *)", - "Bash(rm -rf ./*)", - "Bash(rm -rf ../*)", - "Bash(dd if=/dev/zero*)", - "Bash(mkfs.*)", - "Bash(format:*)", - - // ========== DESTRUCTIVE GIT OPERATIONS ========== - "Bash(git push:*)", - "Bash(git push --force:*)", - "Bash(git push -f:*)", - "Bash(git reset --hard:*)", - "Bash(git clean -f:*)", - "Bash(git clean -fd:*)", - "Bash(git checkout -- .:*)", - "Bash(git branch -D:*)", - "Bash(git restore .:*)", - - // ========== PRIVILEGE ESCALATION ========== - "Bash(sudo:*)", - "Bash(su:*)", - "Bash(doas:*)", - - // ========== FORK BOMBS ========== - "Bash(:(){:|:&};:)", - - // ========== REVERSE SHELLS ========== - "Bash(/dev/tcp/*)", - "Bash(/dev/udp/*)", - "Bash(nc -e /bin/bash*)", - "Bash(bash -c 'exec 1<>/dev/tcp/*')", - - // ========== PIPE-EXEC FROM REMOTE ========== - "Bash(curl * | bash*)", + "Bash(sudo rm -rf /*)", + "Bash(sudo rm -rf /)", + "Bash(dd if=/dev/zero of=/dev/*)", + "Bash(dd if=/dev/random of=/dev/*)", + "Bash(mkfs*)", + "Bash(fdisk*)", + "Bash(parted*)", + "Bash(*fork*bomb*)", + "Bash(> /dev/sda*)", + "Bash(> /dev/nvme*)", + "Bash(> /dev/sd*)", + "Bash(cat /dev/urandom > *)", + "Bash(* | nc -l*)", + "Bash(* | netcat -l*)", + "Bash(* | socat *)", + "Bash(* | base64 -d | sh*)", + "Bash(* | base64 -d | bash*)", "Bash(curl * | sh*)", - "Bash(wget * | bash*)", + "Bash(curl * | bash*)", "Bash(wget * | sh*)", - "Bash(source <(curl*)", - "Bash(bash <(curl*)", - - // ========== CREDENTIAL THEFT ========== - "Bash(cat /etc/shadow*)", - "Bash(cat /etc/passwd*)", - "Bash(cat ~/.ssh/id_rsa*)", - "Bash(cat ~/.aws/credentials*)", - "Bash(cat ~/.docker/config.json*)", - "Bash(cat ~/.kube/config*)", - - // ========== PROCESS KILLING ========== - "Bash(kill -9:*)", - "Bash(killall:*)", - - // ========== LOG & AUDIT DELETION ========== - "Bash(history -c*)", - "Bash(cat /dev/null > ~/.bash_history*)", - - // ========== CLOUD METADATA EXPLOITS ========== + "Bash(wget * | bash*)", + "Bash(sudo passwd*)", + "Bash(passwd*)", + "Bash(sudo useradd*)", + "Bash(sudo userdel*)", + "Bash(sudo usermod*)", + "Bash(sudo groupadd*)", + "Bash(sudo groupdel*)", + "Bash(sudo adduser*)", + "Bash(sudo deluser*)", + "Bash(sudo chmod 777 /*)", + "Bash(sudo chown * /*)", + "Bash(sudo rm /etc/*)", + "Bash(sudo rm -rf /etc/*)", + "Bash(sudo rm /bin/*)", + "Bash(sudo rm /usr/*)", + "Bash(sudo > /etc/*)", + "Bash(nc -l*)", + "Bash(netcat -l*)", + "Bash(socat*)", + "Bash(nmap*)", + "Bash(masscan*)", + "Bash(*xmrig*)", + "Bash(*monero*)", + "Bash(*bitcoin*)", + "Bash(*miner*)", + "Bash(git push --force origin master*)", + "Bash(git push --force origin main*)", + "Bash(git push -f origin master*)", + "Bash(git push -f origin main*)", + "Bash(git reset --hard origin/master*)", + "Bash(git reset --hard origin/main*)", + "Bash(cat ~/.aws/*)", + "Bash(cat ~/.ssh/id_*)", + "Bash(cat /root/.ssh/*)", + "Bash(env | base64*)", + "Bash(printenv | base64*)", + "Bash(set | base64*)", + "Bash(bash -i >& /dev/tcp/*)", + "Bash(sh -i >& /dev/tcp/*)", + "Bash(python -c 'import socket*)", + "Bash(php -r '$sock*)", + "Bash(ruby -rsocket*)", + "Bash(perl -e 'use Socket*)", + "Bash(docker run --privileged *)", + "Bash(docker run --pid=host *)", + "Bash(docker run --net=host *)", + "Bash(docker run -v /:/host *)", + "Bash(docker run -v /etc:/etc *)", + "Bash(docker run -v /var/run/docker.sock:/var/run/docker.sock *)", + "Bash(history | grep -i password*)", + "Bash(history | grep -i token*)", + "Bash(history | grep -i secret*)", + "Bash(history | grep -i key*)", + "Bash(grep -r password /etc/*)", + "Bash(grep -r token /etc/*)", + "Bash(find / -name id_rsa*)", + "Bash(find / -name *.key*)", + "Bash(find / -name *.pem*)", + "Bash(shutdown*)", + "Bash(reboot*)", + "Bash(halt*)", + "Bash(poweroff*)", + "Bash(init 0*)", + "Bash(init 6*)", + "Bash(sudo shutdown*)", + "Bash(sudo reboot*)", + "Bash(sudo ufw disable*)", + "Bash(sudo iptables -F*)", + "Bash(sudo iptables --flush*)", + "Bash(sudo systemctl stop firewalld*)", + "Bash(sudo service iptables stop*)", + "Bash(curl * | sudo *)", + "Bash(wget * | sudo *)", + "Bash(curl -s * | sh*)", + "Bash(wget -qO- * | sh*)", + "Bash(rm /var/log/*)", + "Bash(rm -rf /var/log/*)", + "Bash(> /var/log/*)", + "Bash(echo > /var/log/*)", + "Bash(truncate -s 0 /var/log/*)", + "Bash(npm install -g * --unsafe-perm*)", + "Bash(pip install * --break-system-packages*)", + "Bash(gem install * --no-user-install*)", + "Bash(insmod *)", + "Bash(rmmod *)", + "Bash(modprobe *)", + "Bash(sysctl -w *)", + "Bash(chmod -R 777 /*)", + "Bash(chmod 777 /etc/*)", + "Bash(chmod 777 /bin/*)", + "Bash(chmod 777 /usr/*)", + "Bash(chown -R * /*)", + "Bash(*bitcoin-cli*)", + "Bash(*ethereum*)", + "Bash(*wallet.dat*)", + "Bash(*privatekey*)", + "Bash(find / -type f -delete*)", + "Bash(find / -type d -delete*)", + "Bash(rm -rf /home/*)", + "Bash(rm -rf /var/*)", + "Bash(rm -rf /opt/*)", + "Bash(gdb -p *)", + "Bash(ptrace *)", + "Bash(LD_PRELOAD=*)", + "Bash(sudo -l*)", + "Bash(sudo -V*)", + "Bash(sudo su*)", + "Bash(sudo su -*)", + "Bash(sudo -i*)", + "Bash(pkexec *)", + "Bash(export PATH=*)", + "Bash(export LD_LIBRARY_PATH=*)", + "Bash(export PYTHONPATH=*)", + "Bash(nsenter *)", + "Bash(docker run --cap-add=ALL *)", + "Bash(docker run --security-opt *)", + "Bash(systemctl mask *)", + "Bash(systemctl disable *)", + "Bash(systemctl daemon-reload*)", "Bash(curl http://169.254.169.254/*)", + "Bash(wget http://169.254.169.254/*)", "Bash(curl http://metadata.google.internal/*)", - - // ========== DANGEROUS EVAL / INJECTION ========== - "Bash(eval:*)", - - // ========== KERNEL / SYSTEM MODIFICATION ========== - "Bash(sysctl -w*)", - "Bash(modprobe:*)", - "Bash(insmod:*)", - "Bash(rmmod:*)", - "Bash(iptables -F*)" + "Bash(exec < /dev/tcp/*)", + "Bash(exec > /dev/tcp/*)", + "Bash(exec 3<>/dev/tcp/*)" ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true, + "frontend-design@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true, + "plugin-dev@claude-plugins-official": true, + "mcp-server-dev@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true } } diff --git a/contracts/accounts.js b/contracts/accounts.js index 02ef26631..9418d0488 100644 --- a/contracts/accounts.js +++ b/contracts/accounts.js @@ -141,6 +141,12 @@ const ACCOUNT_24 = { "0x40fec058aab032f7d099a0a476868cb77e47633172634e96f02267249f64bce1" }; +const ACCOUNT_25 = { + address: "0x6937E19EcA5b87De580Fa26B11ca0E91E1ca0614", + privateKey: + "0x6b340639bc944c250c290bbcaf8d1cd3ee2c1d6e98b35af8716b88cf81350cc9" +}; + const ACCOUNTS = [ ACCOUNT_1, ACCOUNT_2, @@ -165,7 +171,8 @@ const ACCOUNTS = [ ACCOUNT_21, ACCOUNT_22, ACCOUNT_23, - ACCOUNT_24 + ACCOUNT_24, + ACCOUNT_25 ]; module.exports = { @@ -193,5 +200,6 @@ module.exports = { ACCOUNT_21, ACCOUNT_22, ACCOUNT_23, - ACCOUNT_24 + ACCOUNT_24, + ACCOUNT_25 }; diff --git a/contracts/hardhat.config.js b/contracts/hardhat.config.js index 6e47f42da..cb158ec62 100644 --- a/contracts/hardhat.config.js +++ b/contracts/hardhat.config.js @@ -254,7 +254,10 @@ module.exports = { "Seaport", "OpenSeaWrapper", "OpenSeaWrapperFactory", - "DRFeeMutualizer" + "DRFeeMutualizer", + "MockERC2612Token", + "MockERC3009Token", + "MockPermit2" ], except: ["MockDRFeeMutualizer"] } diff --git a/contracts/scripts/deploy-other-tokens.js b/contracts/scripts/deploy-other-tokens.js new file mode 100644 index 000000000..065d4980c --- /dev/null +++ b/contracts/scripts/deploy-other-tokens.js @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { Contract } = require("ethers"); +const hre = require("hardhat"); +const ethers = hre.ethers; +const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + +async function deployWeth() { + const WETH9 = await ethers.getContractFactory("WETH9"); + const weth = await WETH9.deploy(); + await weth.waitForDeployment(); + return weth; +} + +async function deployERC3009Token() { + const MockERC3009Token = await ethers.getContractFactory("MockERC3009Token"); + const mockERC3009Token = await MockERC3009Token.deploy( + "ERC3009Token", + "ERC3009" + ); + await mockERC3009Token.waitForDeployment(); + return mockERC3009Token; +} + +async function deployERC2612Token() { + const MockERC2612Token = await ethers.getContractFactory("MockERC2612Token"); + const mockERC2612Token = await MockERC2612Token.deploy( + "ERC2612Token", + "ERC2612" + ); + await mockERC2612Token.waitForDeployment(); + return mockERC2612Token; +} + +async function deployPermit2() { + const MockPermit2 = await ethers.getContractFactory("MockPermit2"); + const mockPermit2 = await MockPermit2.deploy(); + await mockPermit2.waitForDeployment(); + // Inject MockPermit2 at the canonical Permit2 address. The Permit2 + // sub-context relies on this code being present at PERMIT2_ADDRESS so + // `TokenTransferAuthorizationLib._consumePermit2` calls land on it. + const code = await ethers.provider.getCode(await mockPermit2.getAddress()); + await hre.network.provider.send("hardhat_setCode", [PERMIT2_ADDRESS, code]); + return new Contract(PERMIT2_ADDRESS, MockPermit2.interface, ethers.provider); +} + +exports.deployERC3009Token = deployERC3009Token; +exports.deployERC2612Token = deployERC2612Token; +exports.deployPermit2 = deployPermit2; +exports.deployWeth = deployWeth; diff --git a/contracts/scripts/deploy-weth.js b/contracts/scripts/deploy-weth.js deleted file mode 100644 index e7669b1a9..000000000 --- a/contracts/scripts/deploy-weth.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const hre = require("hardhat"); -const ethers = hre.ethers; - -async function deployWeth() { - const WETH9 = await ethers.getContractFactory("WETH9"); - const weth = await WETH9.deploy(); - await weth.waitForDeployment(); - return weth; -} - -exports.deployWeth = deployWeth; diff --git a/contracts/scripts/deploy.js b/contracts/scripts/deploy.js index 4b2af9904..af6c7d3d0 100644 --- a/contracts/scripts/deploy.js +++ b/contracts/scripts/deploy.js @@ -25,7 +25,12 @@ const { deploySeaport } = require("./deploy-seaport"); const { ZeroAddress } = require("ethers"); const { deployWrappers } = require("./deploy-wrappers.js"); const { deployDRFeeMutualizer } = require("./deploy-dRFeeMutualizer.js"); -const { deployWeth } = require("./deploy-weth.js"); +const { + deployWeth, + deployERC2612Token, + deployERC3009Token, + deployPermit2 +} = require("./deploy-other-tokens.js"); async function main() { const { addresses } = await deployAndMintMockNFTAuthTokens(); @@ -42,7 +47,7 @@ async function main() { await deploySuite("localhost", undefined); const mockTokens = ["Foreign20", "Foreign721", "Foreign1155"]; const deployedTokens = await deployMockTokens([...mockTokens]); - let foreign20Token; + const erc20sForDisputeResolver = new Map(); for (const [index, mockToken] of Object.entries(mockTokens)) { console.log( `✅ Mock token ${mockToken} has been deployed at ${await deployedTokens[ @@ -50,7 +55,10 @@ async function main() { ].getAddress()}` ); if (mockToken === "Foreign20") { - foreign20Token = await deployedTokens[index].getAddress(); + erc20sForDisputeResolver.set( + mockToken, + await deployedTokens[index].getAddress() + ); } } const file = await fs.readFile( @@ -64,10 +72,65 @@ async function main() { const priceDiscoveryClient = deployedContracts.find( (c) => c.name === "BosonPriceDiscoveryClient" )?.address; + + // Deploy Seaport contract + const mockSeaport = await deploySeaport(); + const seaportAddress = await mockSeaport.getAddress(); + console.log(`✅ Seaport Contract has been deployed at ${seaportAddress}`); + // Deploy wrappers contracts + const { openSeaWrapperFactory } = await deployWrappers( + protocolDiamond, + priceDiscoveryClient, + seaportAddress + ); + console.log( + `✅ OpenSeaWrapperFactory Contract has been deployed at ${await openSeaWrapperFactory.getAddress()}` + ); + + // Deploy WETH contract + const weth = await deployWeth(); + const wethAddress = await weth.getAddress(); + console.log(`✅ WETH Contract has been deployed at ${wethAddress}`); + process.env.WETH_ADDRESS = wethAddress; + // Deploy DRFeeMutualizer + const dRFeeMutualizer = await deployDRFeeMutualizer( + protocolDiamond, + process.env.FORWARDER_ADDRESS, + wethAddress + ); + const dRFeeMutualizerAddress = await dRFeeMutualizer.getAddress(); + console.log( + `✅ DRFeeMutualizer Contract has been deployed at ${dRFeeMutualizerAddress}` + ); + + // Deploy MockERC3009Token contract + const mockERC3009Token = await deployERC3009Token(); + const mockERC3009TokenAddress = await mockERC3009Token.getAddress(); + console.log( + `✅ MockERC3009Token Contract has been deployed at ${mockERC3009TokenAddress}` + ); + erc20sForDisputeResolver.set("MockERC3009Token", mockERC3009TokenAddress); + + // Deploy MockERC2612Token contract + const mockERC2612Token = await deployERC2612Token(); + const mockERC2612TokenAddress = await mockERC2612Token.getAddress(); + console.log( + `✅ MockERC2612Token Contract has been deployed at ${mockERC2612TokenAddress}` + ); + erc20sForDisputeResolver.set("MockERC2612Token", mockERC2612TokenAddress); + + // Deploy MockPermit2 contract + const mockPermit2 = await deployPermit2(); + const mockPermit2Address = await mockPermit2.getAddress(); + console.log( + `✅ MockPermit2 Contract has been deployed at ${mockPermit2Address}` + ); + erc20sForDisputeResolver.set("MockPermit2", mockPermit2Address); + + // Create default dispute resolver const accounts = await ethers.getSigners(); const disputeResolverSigner = accounts[1]; const disputeResolver = disputeResolverSigner.address; - // Create default dispute resolver const accountHandler = await ethers.getContractAt( "IBosonAccountHandler", protocolDiamond @@ -92,11 +155,13 @@ async function main() { tokenName: "Native", feeAmount: "0" }, - { - tokenAddress: foreign20Token, - tokenName: "Foreign20", - feeAmount: "0" - } + ...Array.from(erc20sForDisputeResolver.entries()).map( + ([tokenName, tokenAddress]) => ({ + tokenAddress, + tokenName, + feeAmount: "0" + }) + ) ], [] ); @@ -108,34 +173,7 @@ async function main() { console.log( `✅ Dispute resolver created. ID: ${disputeResolverId} Wallet: ${disputeResolver}` ); - // Deploy Seaport contract - const mockSeaport = await deploySeaport(); - const seaportAddress = await mockSeaport.getAddress(); - console.log(`✅ Seaport Contract has been deployed at ${seaportAddress}`); - // Deploy wrappers contracts - const { openSeaWrapperFactory } = await deployWrappers( - protocolDiamond, - priceDiscoveryClient, - seaportAddress - ); - console.log( - `✅ OpenSeaWrapperFactory Contract has been deployed at ${await openSeaWrapperFactory.getAddress()}` - ); - // Deploy WETH contract - const weth = await deployWeth(); - const wethAddress = await weth.getAddress(); - console.log(`✅ WETH Contract has been deployed at ${wethAddress}`); - process.env.WETH_ADDRESS = wethAddress; - // Deploy DRFeeMutualizer - const dRFeeMutualizer = await deployDRFeeMutualizer( - protocolDiamond, - process.env.FORWARDER_ADDRESS, - wethAddress - ); - const dRFeeMutualizerAddress = await dRFeeMutualizer.getAddress(); - console.log( - `✅ DRFeeMutualizer Contract has been deployed at ${dRFeeMutualizerAddress}` - ); + // Set specific configuration values (needed for tests) const deployer = accounts[0]; const configHandler = await ethers.getContractAt( diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index f2bc327f7..b3b20a054 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -38,7 +38,7 @@ services: - host.docker.internal:host-gateway boson-protocol-node: - image: boson-protocol-node:858661679cc8ba2be97eedf3cbc3acd0f5c1903e_2 + image: boson-protocol-node:858661679cc8ba2be97eedf3cbc3acd0f5c1903e_4 build: context: ../. dockerfile: ./contracts/Dockerfile diff --git a/e2e/tests/core-sdk-token-auth.test.ts b/e2e/tests/core-sdk-token-auth.test.ts new file mode 100644 index 000000000..4dff7f4be --- /dev/null +++ b/e2e/tests/core-sdk-token-auth.test.ts @@ -0,0 +1,1046 @@ +import { + abis, + FullOfferArgs, + GatingType, + OfferCreator +} from "@bosonprotocol/common"; +import { parseEther } from "@ethersproject/units"; +import { + BigNumber, + BigNumberish, + constants, + Contract, + utils, + Wallet +} from "ethers"; +import { CoreSDK } from "../../packages/core-sdk/src"; +import { TransferAuthorization } from "../../packages/core-sdk/src/erc20/handler"; +import EvaluationMethod from "../../contracts/protocol-contracts/scripts/domain/EvaluationMethod"; +import TokenType from "../../contracts/protocol-contracts/scripts/domain/TokenType"; +import { + MSEC_PER_DAY, + MSEC_PER_SEC +} from "../../packages/common/src/utils/timestamp"; +import { + buildFullOfferArgs, + createDisputeResolver, + createFundedWallet, + createOffer, + createOfferWithCondition, + createSeller, + deployerWallet, + ensureMintedERC1155, + initCoreSDKWithFundedWallet, + initSellerAndBuyerSDKs, + mockErc20Contract, + MOCK_ERC1155_ADDRESS, + MOCK_ERC20_ADDRESS, + MOCK_ERC2612_ADDRESS, + MOCK_ERC3009_ADDRESS, + MOCK_PERMIT2_ADDRESS, + seedWallet25 +} from "./utils"; +import { MOCK_ERC20_ABI } from "./mockAbis"; + +jest.setTimeout(120_000); + +const seedWallet = seedWallet25; // be sure the seedWallet is not used by another test (to allow concurrent run) + +// ─── Strategy table ──────────────────────────────────────────────────────────── + +type Strategy = "ERC3009" | "EIP2612" | "Permit2"; +const STRATEGIES: Strategy[] = ["ERC3009", "EIP2612", "Permit2"]; + +const ERC3009_DOMAIN = { name: "ERC3009Token", version: "1" }; +const ERC2612_DOMAIN = { name: "ERC2612Token", version: "1" }; + +function exchangeTokenFor(strategy: Strategy): string { + switch (strategy) { + case "ERC3009": + return MOCK_ERC3009_ADDRESS; + case "EIP2612": + return MOCK_ERC2612_ADDRESS; + case "Permit2": + return MOCK_ERC20_ADDRESS; + } +} + +function tokenAbiFor(strategy: Strategy): unknown[] { + switch (strategy) { + case "ERC3009": + return abis.ERC3009TokenABI; + case "EIP2612": + return abis.ERC2612TokenABI; + case "Permit2": + return MOCK_ERC20_ABI; + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +async function mintMockToken( + wallet: Wallet, + strategy: Strategy, + amount: BigNumberish +): Promise { + const token = new Contract( + exchangeTokenFor(strategy), + tokenAbiFor(strategy), + wallet + ); + await (await token.mint(wallet.address, amount)).wait(); +} + +async function approvePermit2(wallet: Wallet): Promise { + const token = new Contract(MOCK_ERC20_ADDRESS, abis.ERC20ABI, wallet); + await ( + await token.approve(MOCK_PERMIT2_ADDRESS, constants.MaxUint256) + ).wait(); +} + +async function setUpFunderWallet( + wallet: Wallet, + strategy: Strategy, + amount: BigNumberish +): Promise { + await mintMockToken(wallet, strategy, amount); + if (strategy === "Permit2") { + await approvePermit2(wallet); + } +} + +async function signAuth( + coreSDK: CoreSDK, + strategy: Strategy, + exchangeToken: string, + value: BigNumberish +): Promise { + switch (strategy) { + case "ERC3009": + return coreSDK.signReceiveWithErc3009Authorization( + exchangeToken, + ERC3009_DOMAIN, + value, + 0, + constants.MaxUint256 + ); + case "EIP2612": + return coreSDK.signReceiveWithErc2612Permit( + exchangeToken, + ERC2612_DOMAIN, + value, + constants.MaxUint256 + ); + case "Permit2": + return coreSDK.signReceiveWithPermit2( + exchangeToken, + value, + constants.MaxUint256 + ); + } +} + +async function createDrForToken( + exchangeToken: string, + drFeeAmount: BigNumberish +) { + const { fundedWallet: drFundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const drAddress = drFundedWallet.address.toLowerCase(); + const { disputeResolver } = await createDisputeResolver( + drFundedWallet, + deployerWallet, + { + assistant: drAddress, + admin: drAddress, + treasury: drAddress, + metadataUri: "", + escalationResponsePeriodInMS: 90 * MSEC_PER_DAY - 1 * MSEC_PER_SEC, + fees: [ + { + feeAmount: drFeeAmount, + tokenAddress: exchangeToken, + tokenName: "ERC20" + } + ], + sellerAllowList: [] + } + ); + return disputeResolver; +} + +const noCondition = { + method: EvaluationMethod.None, + tokenType: TokenType.MultiToken, + tokenAddress: constants.AddressZero, + gatingType: GatingType.PerAddress, + minTokenId: "0", + maxTokenId: "0", + threshold: "0", + maxCommits: "0" +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe("core-sdk-token-auth", () => { + describe("erc3009", () => { + test("sign and verify ERC3009 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC3009_ADDRESS; + const amount = "1000000000000000000"; // 1 token with 18 decimals + + const token = new Contract( + tokenAddress, + abis.ERC3009TokenABI, + fundedWallet + ); + + // Mint tokens to the signer so the authorization actually transfers value. + await (await token.mint(fundedWallet.address, amount)).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + + const auth = await coreSDK.signReceiveWithErc3009Authorization( + tokenAddress, + { name: "ERC3009Token", version: "1" }, + amount, + 0, + constants.MaxUint256, + { spender: recipientWallet.address } + ); + const { r, s, v, signature } = auth; + + expect(typeof r).toBe("string"); + expect(typeof s).toBe("string"); + expect(typeof v).toBe("number"); + expect(typeof signature).toBe("string"); + expect(auth.strategy).toBe("ERC3009"); + expect(auth.data.validAfter.toString()).toBe("0"); + expect(auth.data.validBefore.toString()).toBe( + constants.MaxUint256.toString() + ); + + // The recipient pulls the funds by calling receiveWithAuthorization. + const tokenAsRecipient = token.connect(recipientWallet); + await ( + await tokenAsRecipient.receiveWithAuthorization( + fundedWallet.address, + recipientWallet.address, + amount, + auth.data.validAfter, + auth.data.validBefore, + auth.data.nonce, + v, + r, + s + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + + // Authorization nonce is now consumed. + expect( + await token.authorizationState(fundedWallet.address, auth.data.nonce) + ).toBe(true); + }); + + test("sign typed data externally (returnTypedDataToSign: true) and verify ERC3009 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC3009_ADDRESS; + const amount = "1000000000000000000"; // 1 token with 18 decimals + + const token = new Contract( + tokenAddress, + abis.ERC3009TokenABI, + fundedWallet + ); + + await (await token.mint(fundedWallet.address, amount)).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + + // 1. Ask the SDK for the EIP-712 payload to be signed externally. + const typedData = await coreSDK.signReceiveWithErc3009Authorization( + tokenAddress, + { name: "ERC3009Token", version: "1" }, + amount, + 0, + constants.MaxUint256, + { spender: recipientWallet.address, returnTypedDataToSign: true } + ); + + // 2. Sign with the user wallet directly. ethers handles EIP712Domain + // internally, so pass only the inner struct type. + const allTypes = typedData.types as Record< + string, + { name: string; type: string }[] + >; + const signature = await fundedWallet._signTypedData( + typedData.domain, + { ReceiveWithAuthorization: allTypes.ReceiveWithAuthorization }, + typedData.message + ); + const { r, s, v } = utils.splitSignature(signature); + const nonce = typedData.message.nonce as string; + + // 3. Recipient pulls the funds using the externally-built signature. + const tokenAsRecipient = token.connect(recipientWallet); + await ( + await tokenAsRecipient.receiveWithAuthorization( + fundedWallet.address, + recipientWallet.address, + amount, + 0, + constants.MaxUint256, + nonce, + v, + r, + s + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + expect(await token.authorizationState(fundedWallet.address, nonce)).toBe( + true + ); + }); + }); + describe("erc2612", () => { + test("sign and verify ERC2612 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC2612_ADDRESS; + const amount = "1000000000000000000"; // 1 token with 18 decimals + + const token = new Contract( + tokenAddress, + abis.ERC2612TokenABI, + fundedWallet + ); + + await (await token.mint(fundedWallet.address, amount)).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + const nonceBefore: BigNumber = await token.nonces(fundedWallet.address); + + const auth = await coreSDK.signReceiveWithErc2612Permit( + tokenAddress, + { name: "ERC2612Token", version: "1" }, + amount, + constants.MaxUint256, + { spender: recipientWallet.address } + ); + const { r, s, v, signature } = auth; + + expect(typeof r).toBe("string"); + expect(typeof s).toBe("string"); + expect(typeof v).toBe("number"); + expect(typeof signature).toBe("string"); + expect(auth.strategy).toBe("EIP2612"); + expect(auth.data.deadline.toString()).toBe( + constants.MaxUint256.toString() + ); + + // 1. Apply permit (anyone can submit it; spender does it here). + const tokenAsRecipient = token.connect(recipientWallet); + await ( + await tokenAsRecipient.permit( + fundedWallet.address, + recipientWallet.address, + amount, + auth.data.deadline, + v, + r, + s + ) + ).wait(); + // 2. Spender pulls the funds via transferFrom. + await ( + await tokenAsRecipient.transferFrom( + fundedWallet.address, + recipientWallet.address, + amount + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + + // Permit nonce was advanced. + const nonceAfter: BigNumber = await token.nonces(fundedWallet.address); + expect(nonceAfter.sub(nonceBefore).toString()).toBe("1"); + }); + + test("sign typed data externally (returnTypedDataToSign: true) and verify ERC2612 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC2612_ADDRESS; + const amount = "1000000000000000000"; + + const token = new Contract( + tokenAddress, + abis.ERC2612TokenABI, + fundedWallet + ); + + await (await token.mint(fundedWallet.address, amount)).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + const nonceBefore: BigNumber = await token.nonces(fundedWallet.address); + + // 1. Ask the SDK for the EIP-712 payload to be signed externally. + const typedData = await coreSDK.signReceiveWithErc2612Permit( + tokenAddress, + { name: "ERC2612Token", version: "1" }, + amount, + constants.MaxUint256, + { spender: recipientWallet.address, returnTypedDataToSign: true } + ); + + // 2. Sign with the user wallet directly. + const allTypes = typedData.types as Record< + string, + { name: string; type: string }[] + >; + const signature = await fundedWallet._signTypedData( + typedData.domain, + { Permit: allTypes.Permit }, + typedData.message + ); + const { r, s, v } = utils.splitSignature(signature); + const deadline = typedData.message.deadline as string; + + // 3. Apply permit then transferFrom from the recipient. + const tokenAsRecipient = token.connect(recipientWallet); + await ( + await tokenAsRecipient.permit( + fundedWallet.address, + recipientWallet.address, + amount, + deadline, + v, + r, + s + ) + ).wait(); + await ( + await tokenAsRecipient.transferFrom( + fundedWallet.address, + recipientWallet.address, + amount + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + + const nonceAfter: BigNumber = await token.nonces(fundedWallet.address); + expect(nonceAfter.sub(nonceBefore).toString()).toBe("1"); + }); + }); + describe("Permit2", () => { + test("sign and verify Permit2 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC20_ADDRESS; + const amount = "1000000000000000000"; // 1 token with 18 decimals + + const token = mockErc20Contract.connect(fundedWallet); + const permit2 = new Contract( + MOCK_PERMIT2_ADDRESS, + abis.Permit2ABI, + recipientWallet + ); + + // Mint tokens + pre-approve Permit2 (required once per token). + await (await token.mint(fundedWallet.address, amount)).wait(); + await ( + await token.approve(MOCK_PERMIT2_ADDRESS, constants.MaxUint256) + ).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + + const auth = await coreSDK.signReceiveWithPermit2( + tokenAddress, + amount, + constants.MaxUint256, + { spender: recipientWallet.address } + ); + const { r, s, v, signature } = auth; + + expect(typeof r).toBe("string"); + expect(typeof s).toBe("string"); + expect(typeof v).toBe("number"); + expect(typeof signature).toBe("string"); + expect(auth.strategy).toBe("Permit2"); + expect(auth.data.deadline.toString()).toBe( + constants.MaxUint256.toString() + ); + + // Pre-check: Permit2 nonce bit is clear. + const permit2Nonce = BigNumber.from(auth.data.nonce); + const wordPos = permit2Nonce.shr(8); + const bitPos = permit2Nonce.and(0xff); + const bit = BigNumber.from(1).shl(bitPos.toNumber()); + const bitmapBefore: BigNumber = await permit2.nonceBitmap( + fundedWallet.address, + wordPos + ); + expect(bitmapBefore.and(bit).isZero()).toBe(true); + + // Recipient (= signed spender) pulls funds via Permit2. + await ( + await permit2.permitTransferFrom( + { + permitted: { token: tokenAddress, amount }, + nonce: auth.data.nonce, + deadline: auth.data.deadline + }, + { to: recipientWallet.address, requestedAmount: amount }, + fundedWallet.address, + signature + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + + // Permit2 nonce bit is now set. + const bitmapAfter: BigNumber = await permit2.nonceBitmap( + fundedWallet.address, + wordPos + ); + expect(bitmapAfter.and(bit).eq(bit)).toBe(true); + }); + + test("sign typed data externally (returnTypedDataToSign: true) and verify Permit2 token transfer", async () => { + const { coreSDK, fundedWallet } = + await initCoreSDKWithFundedWallet(seedWallet); + const recipientWallet = await createFundedWallet(seedWallet); + const tokenAddress = MOCK_ERC20_ADDRESS; + const amount = "1000000000000000000"; + + const token = mockErc20Contract.connect(fundedWallet); + const permit2 = new Contract( + MOCK_PERMIT2_ADDRESS, + abis.Permit2ABI, + recipientWallet + ); + + await (await token.mint(fundedWallet.address, amount)).wait(); + await ( + await token.approve(MOCK_PERMIT2_ADDRESS, constants.MaxUint256) + ).wait(); + + const balanceBeforeFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceBeforeTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + + // 1. Ask the SDK for the EIP-712 payload to be signed externally. + const typedData = await coreSDK.signReceiveWithPermit2( + tokenAddress, + amount, + constants.MaxUint256, + { spender: recipientWallet.address, returnTypedDataToSign: true } + ); + + // 2. Sign with the user wallet directly (pass both struct types — ethers + // handles EIP712Domain internally). + const allTypes = typedData.types as Record< + string, + { name: string; type: string }[] + >; + const signature = await fundedWallet._signTypedData( + typedData.domain, + { + PermitTransferFrom: allTypes.PermitTransferFrom, + TokenPermissions: allTypes.TokenPermissions + }, + typedData.message + ); + // Sanity-split for readability (not used by Permit2 directly). + const split = utils.splitSignature(signature); + expect(typeof split.r).toBe("string"); + + const nonce = typedData.message.nonce as string; + const deadline = typedData.message.deadline as string; + + // 3. Permit2.permitTransferFrom with the externally-built signature. + await ( + await permit2.permitTransferFrom( + { + permitted: { token: tokenAddress, amount }, + nonce, + deadline + }, + { to: recipientWallet.address, requestedAmount: amount }, + fundedWallet.address, + signature + ) + ).wait(); + + const balanceAfterFrom: BigNumber = await token.balanceOf( + fundedWallet.address + ); + const balanceAfterTo: BigNumber = await token.balanceOf( + recipientWallet.address + ); + expect(balanceBeforeFrom.sub(balanceAfterFrom).toString()).toBe(amount); + expect(balanceAfterTo.sub(balanceBeforeTo).toString()).toBe(amount); + + // Permit2 nonce bit is now set. + const wordPos = BigNumber.from(nonce).shr(8); + const bitPos = BigNumber.from(nonce).and(0xff); + const bit = BigNumber.from(1).shl(bitPos.toNumber()); + const bitmap: BigNumber = await permit2.nonceBitmap( + fundedWallet.address, + wordPos + ); + expect(bitmap.and(bit).eq(bit)).toBe(true); + }); + }); + + describe("meta-tx with transfer authorizations", () => { + describe("commitToOffer", () => { + STRATEGIES.forEach((strategy) => { + test(`${strategy} auth — non-native exchange token offer`, async () => { + const exchangeToken = exchangeTokenFor(strategy); + + // Fresh DR that accepts this token (drFee = 0 to keep things simple). + const disputeResolver = await createDrForToken(exchangeToken, "0"); + + // Fresh seller + buyer wallets (parallel-safe). + const { sellerCoreSDK, buyerCoreSDK, buyerWallet, sellerWallet } = + await initSellerAndBuyerSDKs(seedWallet); + + // Seller account is required to create an offer. + await createSeller(sellerCoreSDK, sellerWallet.address); + + // sellerDeposit = 0 so the seller doesn't need to pre-deposit anything. + const offer = await createOffer(sellerCoreSDK, { + exchangeToken, + disputeResolverId: disputeResolver.id, + sellerDeposit: "0", + quantityAvailable: 1 + }); + + // Mint exchange token to the buyer (and Permit2-approve, for Permit2). + await setUpFunderWallet(buyerWallet, strategy, offer.price); + + // Buyer signs the transfer authorization for offer.price. + const buyerAuth = await signAuth( + buyerCoreSDK, + strategy, + exchangeToken, + offer.price + ); + + const nonce = Date.now(); + const { r, s, v, functionName, functionSignature } = + await buyerCoreSDK.signMetaTxCommitToOffer({ + offerId: offer.id, + nonce + }); + + const metaTx = await buyerCoreSDK.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v, + transferAuthorizations: [buyerAuth] + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe( + true + ); + }); + }); + }); + + describe("commitToConditionalOffer", () => { + STRATEGIES.forEach((strategy) => { + test(`${strategy} auth — non-native exchange token conditional offer`, async () => { + const exchangeToken = exchangeTokenFor(strategy); + const tokenID = Date.now().toString(); + + const disputeResolver = await createDrForToken(exchangeToken, "0"); + + const { sellerCoreSDK, buyerCoreSDK, buyerWallet, sellerWallet } = + await initSellerAndBuyerSDKs(seedWallet); + + await createSeller(sellerCoreSDK, sellerWallet.address); + + // Mint the gating ERC1155 token to the buyer so the condition holds. + await ensureMintedERC1155(buyerWallet, tokenID, "5"); + + const condition = { + method: EvaluationMethod.Threshold, + tokenType: TokenType.MultiToken, + tokenAddress: MOCK_ERC1155_ADDRESS.toLowerCase(), + gatingType: GatingType.PerAddress, + minTokenId: tokenID, + maxTokenId: tokenID, + threshold: "1", + maxCommits: "3" + }; + + const offer = await createOfferWithCondition( + sellerCoreSDK, + condition, + { + offerParams: { + exchangeToken, + disputeResolverId: disputeResolver.id, + sellerDeposit: "0", + quantityAvailable: 1 + } + } + ); + + await setUpFunderWallet(buyerWallet, strategy, offer.price); + + const buyerAuth = await signAuth( + buyerCoreSDK, + strategy, + exchangeToken, + offer.price + ); + + const nonce = Date.now(); + const { r, s, v, functionName, functionSignature } = + await buyerCoreSDK.signMetaTxCommitToConditionalOffer({ + offerId: offer.id, + tokenId: tokenID, + nonce + }); + + const metaTx = await buyerCoreSDK.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v, + transferAuthorizations: [buyerAuth] + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe( + true + ); + }); + }); + }); + + describe("commitToBuyerOffer", () => { + STRATEGIES.forEach((strategy) => { + test(`${strategy} auth — non-native exchange token buyer-initiated offer`, async () => { + // `commitToBuyerOffer` consumes pre-deposited "available funds" rather + // than pulling tokens inline (unlike `commitToOffer`). The auth feature + // is therefore exercised on the `depositFunds` meta-tx that precedes + // the commit: the auth pulls tokens into the protocol AND the inner + // `depositFunds` call credits them to the user's account. The commit + // itself runs as a regular meta-tx, no auths needed. + const exchangeToken = exchangeTokenFor(strategy); + const drFeeAmount = parseEther("0.001"); + + const disputeResolver = await createDrForToken( + exchangeToken, + drFeeAmount + ); + + const { + sellerCoreSDK: sellerCoreSDKBuyer, + buyerCoreSDK: buyerCoreSDKBuyer, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(seedWallet); + + // Buyer-initiated offer with the chosen exchange token. + // sellerDeposit = 0 so the seller doesn't need to deposit funds upfront. + const buyerInitiatedOffer = await createOffer(buyerCoreSDKBuyer, { + creator: OfferCreator.Buyer, + quantityAvailable: 1, + disputeResolverId: disputeResolver.id, + exchangeToken, + sellerDeposit: "0" + }); + + await setUpFunderWallet( + buyerFundedWallet, + strategy, + buyerInitiatedOffer.price + ); + await setUpFunderWallet(sellerFundedWallet, strategy, drFeeAmount); + + // Seller account is required so the seller can receive a deposit. + const seller = await createSeller( + sellerCoreSDKBuyer, + sellerFundedWallet.address + ); + + // 1. Buyer: sign auth, then relay a depositFunds meta-tx carrying it. + // The auth pulls offer.price into the protocol; the inner deposit + // credits it to the buyer's account. + const buyerAuth = await signAuth( + buyerCoreSDKBuyer, + strategy, + exchangeToken, + buyerInitiatedOffer.price + ); + const buyerDepositNonce = Date.now(); + const buyerDepositSig = + await buyerCoreSDKBuyer.signMetaTxDepositFunds({ + entityId: buyerInitiatedOffer.buyerId, + fundsTokenAddress: exchangeToken, + fundsAmount: buyerInitiatedOffer.price, + nonce: buyerDepositNonce + }); + const buyerDepositTx = await buyerCoreSDKBuyer.relayMetaTransaction({ + functionName: buyerDepositSig.functionName, + functionSignature: buyerDepositSig.functionSignature, + nonce: buyerDepositNonce, + sigR: buyerDepositSig.r, + sigS: buyerDepositSig.s, + sigV: buyerDepositSig.v, + transferAuthorizations: [buyerAuth] + }); + await buyerDepositTx.wait(); + + // 2. Seller: same pattern for drFeeAmount. + const sellerAuth = await signAuth( + sellerCoreSDKBuyer, + strategy, + exchangeToken, + drFeeAmount + ); + const sellerDepositNonce = Date.now() + 1; + const sellerDepositSig = + await sellerCoreSDKBuyer.signMetaTxDepositFunds({ + entityId: seller.id, + fundsTokenAddress: exchangeToken, + fundsAmount: drFeeAmount, + nonce: sellerDepositNonce + }); + const sellerDepositTx = await sellerCoreSDKBuyer.relayMetaTransaction( + { + functionName: sellerDepositSig.functionName, + functionSignature: sellerDepositSig.functionSignature, + nonce: sellerDepositNonce, + sigR: sellerDepositSig.r, + sigS: sellerDepositSig.s, + sigV: sellerDepositSig.v, + transferAuthorizations: [sellerAuth] + } + ); + await sellerDepositTx.wait(); + + // 3. Seller commits — funds are now available, no auths needed. + const commitNonce = Date.now() + 2; + const { r, s, v, functionName, functionSignature } = + await sellerCoreSDKBuyer.signMetaTxCommitToBuyerOffer({ + offerId: buyerInitiatedOffer.id, + sellerParams: {}, + nonce: commitNonce + }); + + const metaTx = await sellerCoreSDKBuyer.relayMetaTransaction({ + functionName, + functionSignature, + nonce: commitNonce, + sigR: r, + sigS: s, + sigV: v + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe( + true + ); + }); + }); + }); + + describe("createOfferAndCommit", () => { + STRATEGIES.forEach((strategy) => { + test(`${strategy} auth — non-native exchange token seller-initiated offer`, async () => { + const exchangeToken = exchangeTokenFor(strategy); + const sellerDeposit = "0"; + const drFeeAmount = "0"; + + const disputeResolver = await createDrForToken( + exchangeToken, + drFeeAmount + ); + + const { + sellerCoreSDK: sellerCoreSDKNew, + buyerCoreSDK: buyerCoreSDKNew, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(seedWallet); + + // Seller is offer creator for seller-initiated offer. + const seller = await createSeller( + sellerCoreSDKNew, + sellerFundedWallet.address + ); + + const fullOfferArgsUnsigned = await buildFullOfferArgs( + buyerCoreSDKNew, // buyer calls createOfferAndCommit + sellerCoreSDKNew, // seller signs the offer + noCondition, + { + committer: buyerFundedWallet.address, + offerCreator: sellerFundedWallet.address, + sellerId: seller.id, + sellerOfferParams: { + collectionIndex: 0, + mutualizerAddress: constants.AddressZero, + royaltyInfo: { recipients: [], bps: [] } + }, + useDepositedFunds: true, + creator: OfferCreator.Seller, + feeLimit: parseEther("0.1") + }, + { + offerParams: { + disputeResolverId: disputeResolver.id, + exchangeToken, + sellerDeposit + } + } + ); + + const { signature } = await sellerCoreSDKNew.signFullOffer({ + fullOfferArgsUnsigned + }); + const fullOfferArgs: FullOfferArgs = { + ...fullOfferArgsUnsigned, + signature + }; + + // Buyer (committer) is the one whose funds get pulled. + await setUpFunderWallet( + buyerFundedWallet, + strategy, + fullOfferArgsUnsigned.price + ); + // The seller doesn't actually transfer anything (sellerDeposit=0 and + // useDepositedFunds=true), but `prepareOfferForCommit` still advances + // the queue head for the seller-deposit slot, so a signed entry must + // be supplied there. A zero-amount auth is enough. + if (strategy === "Permit2") { + await approvePermit2(sellerFundedWallet); + } + + const sellerDiscardAuth = await signAuth( + sellerCoreSDKNew, + strategy, + exchangeToken, + sellerDeposit + ); + const buyerAuth = await signAuth( + buyerCoreSDKNew, + strategy, + exchangeToken, + fullOfferArgsUnsigned.price + ); + + const nonce = Date.now(); + const { r, s, v, functionName, functionSignature } = + await buyerCoreSDKNew.signMetaTxCreateOfferAndCommit({ + createOfferAndCommitArgs: fullOfferArgs, + nonce + }); + + const metaTx = await buyerCoreSDKNew.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v, + // Queue layout: [seller-deposit slot (discarded), buyer's price]. + transferAuthorizations: [sellerDiscardAuth, buyerAuth] + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe( + true + ); + }); + }); + }); + }); +}); diff --git a/e2e/tests/utils.ts b/e2e/tests/utils.ts index 39c34a8fb..c17f68db1 100644 --- a/e2e/tests/utils.ts +++ b/e2e/tests/utils.ts @@ -71,7 +71,8 @@ import { ACCOUNT_21, ACCOUNT_22, ACCOUNT_23, - ACCOUNT_24 + ACCOUNT_24, + ACCOUNT_25 } from "../../contracts/accounts"; import { DR_FEE_MUTUALIZER_ABI, @@ -111,6 +112,18 @@ export const MOCK_ERC1155_ADDRESS = (getFirstEnvConfig("local").contracts.testErc1155 as string) || "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf"; +export const MOCK_ERC3009_ADDRESS = + (getFirstEnvConfig("local").contracts.testErc3009 as string) || + "0x809d550fca64d94Bd9F66E60752A544199cfAC3D"; + +export const MOCK_ERC2612_ADDRESS = + (getFirstEnvConfig("local").contracts.testErc2612 as string) || + "0x4c5859f0F772848b2D91F1D83E2Fe57935348029"; + +export const MOCK_PERMIT2_ADDRESS = + (getFirstEnvConfig("local").contracts.permit2 as string) || + "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + export const MOCK_FORWARDER_ADDRESS = (getFirstEnvConfig("local").contracts.forwarder as string) || "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"; @@ -224,6 +237,8 @@ export const seedWallet22 = new Wallet(ACCOUNT_22.privateKey, provider); export const seedWallet23 = new Wallet(ACCOUNT_23.privateKey, provider); // seedWallets used by opensea-price-discovery.test.ts export const seedWallet24 = new Wallet(ACCOUNT_24.privateKey, provider); +// seedWallets used by core-sdk-token-auth.test.ts +export const seedWallet25 = new Wallet(ACCOUNT_25.privateKey, provider); export const mockErc20Contract = new Contract( MOCK_ERC20_ADDRESS, diff --git a/packages/common/src/abis/MockERC2612Token.json b/packages/common/src/abis/MockERC2612Token.json new file mode 100644 index 000000000..2fa56f9d4 --- /dev/null +++ b/packages/common/src/abis/MockERC2612Token.json @@ -0,0 +1,446 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_symbol", + "type": "string" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "InvalidShortString", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "str", + "type": "string" + } + ], + "name": "StringTooLong", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "EIP712DomainChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { + "internalType": "bytes1", + "name": "fields", + "type": "bytes1" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "version", + "type": "string" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "verifyingContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "uint256[]", + "name": "extensions", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/packages/common/src/abis/MockERC3009Token.json b/packages/common/src/abis/MockERC3009Token.json new file mode 100644 index 000000000..581db12b9 --- /dev/null +++ b/packages/common/src/abis/MockERC3009Token.json @@ -0,0 +1,447 @@ +[ + { + "inputs": [ + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_symbol", + "type": "string" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AuthorizationExpired", + "type": "error" + }, + { + "inputs": [], + "name": "AuthorizationNotYetValid", + "type": "error" + }, + { + "inputs": [], + "name": "AuthorizationUsed", + "type": "error" + }, + { + "inputs": [], + "name": "CallerMustBeRecipient", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAuthorization", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ERC712_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RECEIVE_WITH_AUTHORIZATION_TYPEHASH", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "authorizationState", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "validAfter", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "validBefore", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "nonce", + "type": "bytes32" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "receiveWithAuthorization", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/packages/common/src/abis/MockPermit2.json b/packages/common/src/abis/MockPermit2.json new file mode 100644 index 000000000..8ab25b860 --- /dev/null +++ b/packages/common/src/abis/MockPermit2.json @@ -0,0 +1,146 @@ +[ + { + "inputs": [], + "name": "InvalidAmount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSignatureLength", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSigner", + "type": "error" + }, + { + "inputs": [], + "name": "NonceUsed", + "type": "error" + }, + { + "inputs": [], + "name": "PermitExpired", + "type": "error" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "NAME", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "nonceBitmap", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "internalType": "struct MockPermit2.TokenPermissions", + "name": "permitted", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "internalType": "struct MockPermit2.PermitTransferFrom", + "name": "permit", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "requestedAmount", + "type": "uint256" + } + ], + "internalType": "struct MockPermit2.SignatureTransferDetails", + "name": "transferDetails", + "type": "tuple" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "permitTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/packages/common/src/abis/index.ts b/packages/common/src/abis/index.ts index e7a094cb5..1bc16222c 100644 --- a/packages/common/src/abis/index.ts +++ b/packages/common/src/abis/index.ts @@ -25,8 +25,11 @@ import IBosonOrchestrationHandlerABI from "./IBosonOrchestrationHandler.json"; import IBosonPriceDiscoveryHandlerABI from "./IBosonPriceDiscoveryHandler.json"; import IBosonVoucherABI from "./IBosonVoucher.json"; import IDRFeeMutualizerABI from "./IDRFeeMutualizer.json"; +import ERC2612TokenABI from "./MockERC2612Token.json"; +import ERC3009TokenABI from "./MockERC3009Token.json"; import MockForwarderABI from "./MockForwarder.json"; import NativeMetaTransactionABI from "./MockNativeMetaTransaction.json"; +import Permit2ABI from "./MockPermit2.json"; import OpenSeaWrapperABI from "./OpenSeaWrapper.json"; import OpenSeaWrapperFactoryABI from "./OpenSeaWrapperFactory.json"; import ProtocolDiamondABI from "./ProtocolDiamond.json"; @@ -59,8 +62,11 @@ export { IBosonPriceDiscoveryHandlerABI, IBosonVoucherABI, IDRFeeMutualizerABI, + ERC2612TokenABI, + ERC3009TokenABI, MockForwarderABI, NativeMetaTransactionABI, + Permit2ABI, OpenSeaWrapperABI, OpenSeaWrapperFactoryABI, ProtocolDiamondABI, diff --git a/packages/common/src/configs.ts b/packages/common/src/configs.ts index ae3250d35..c963f4533 100644 --- a/packages/common/src/configs.ts +++ b/packages/common/src/configs.ts @@ -36,9 +36,12 @@ export const envConfigs = { contracts: { protocolDiamond: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", priceDiscoveryClient: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", testErc20: "0x70e0bA845a1A0F2DA3359C97E0285013525FFC49", // Foreign20 contract testErc721: "0x4826533B4897376654Bb4d4AD88B7faFD0C98528", // Foreign721 contract testErc1155: "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf", // Foreign1155 contract + testErc3009: "0x809d550fca64d94Bd9F66E60752A544199cfAC3D", // MockERC3009Token contract + testErc2612: "0x4c5859f0F772848b2D91F1D83E2Fe57935348029", // MockERC2612Token contract forwarder: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", // MockForwarder contract seaport: "0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf", // MockSeaport contract openseaWrapper: "0x9d4454B023096f34B160D6B654540c56A1F81688" @@ -76,6 +79,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.testing[80002].protocolDiamond, priceDiscoveryClient: protocolAddresses.testing[80002].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0xd240234dacd7ffdca7e4effcf6c7190885d7e2f0", // https://github.com/bosonprotocol/boson-protocol-contracts/blob/main/scripts/config/client-upgrade.js#L11 openseaWrapper: "0x6e9C25b48161A2aC6A854af3bc596d3190F0B5A3" }, @@ -110,6 +114,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.testing[11155111].protocolDiamond, priceDiscoveryClient: protocolAddresses.testing[11155111].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0xbdeA59c8801658561a16fF58D68FC2b198DE4E93", // https://github.com/bosonprotocol/boson-protocol-contracts/blob/main/scripts/config/client-upgrade.js#L10 openseaWrapper: "0xf4e888DfCBD71b08a3Aa5Cf15d5124Cfd7205433" }, @@ -138,6 +143,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.testing[84532].protocolDiamond, priceDiscoveryClient: protocolAddresses.testing[84532].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "", openseaWrapper: "" }, @@ -170,6 +176,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.testing[11155420].protocolDiamond, priceDiscoveryClient: protocolAddresses.testing[11155420].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "", openseaWrapper: "" }, @@ -202,6 +209,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.testing[421614].protocolDiamond, priceDiscoveryClient: protocolAddresses.testing[421614].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "", openseaWrapper: "" }, @@ -236,6 +244,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.staging[80002].protocolDiamond, priceDiscoveryClient: protocolAddresses.staging[80002].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0xd240234dacd7ffdca7e4effcf6c7190885d7e2f0", // https://github.com/bosonprotocol/boson-protocol-contracts/blob/main/scripts/config/client-upgrade.js#L11 openseaWrapper: "0x6678663A66C228BA79C8B2ABB4b4D797C6215026" }, @@ -270,6 +279,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.staging[11155111].protocolDiamond, priceDiscoveryClient: protocolAddresses.staging[11155111].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0xbdeA59c8801658561a16fF58D68FC2b198DE4E93" // https://github.com/bosonprotocol/boson-protocol-contracts/blob/main/scripts/config/client-upgrade.js#L10 }, metaTx: undefined, @@ -297,6 +307,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.staging[84532].protocolDiamond, priceDiscoveryClient: protocolAddresses.staging[84532].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { @@ -328,6 +339,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.staging[11155420].protocolDiamond, priceDiscoveryClient: protocolAddresses.staging[11155420].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { @@ -359,6 +371,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.staging[421614].protocolDiamond, priceDiscoveryClient: protocolAddresses.staging[421614].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { @@ -392,6 +405,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.production[137].protocolDiamond, priceDiscoveryClient: protocolAddresses.production[137].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0xf0511f123164602042ab2bCF02111fA5D3Fe97CD" }, metaTx: { @@ -424,6 +438,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.production[1].protocolDiamond, priceDiscoveryClient: protocolAddresses.production[1].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "0x84a0856b038eaAd1cC7E297cF34A7e72685A8693" // https://docs-gasless.biconomy.io/misc/contract-addresses }, metaTx: undefined, @@ -451,6 +466,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.production[8453].protocolDiamond, priceDiscoveryClient: protocolAddresses.production[8453].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { @@ -482,6 +498,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.production[10].protocolDiamond, priceDiscoveryClient: protocolAddresses.production[10].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { @@ -513,6 +530,7 @@ export const envConfigs = { protocolDiamond: protocolAddresses.production[42161].protocolDiamond, priceDiscoveryClient: protocolAddresses.production[42161].priceDiscoveryClient, + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", forwarder: "" }, metaTx: { diff --git a/packages/common/src/types/configs.ts b/packages/common/src/types/configs.ts index 99110f5b7..903bdf9d3 100644 --- a/packages/common/src/types/configs.ts +++ b/packages/common/src/types/configs.ts @@ -7,9 +7,12 @@ export type ContractAddresses = { testErc721?: string; testErc20?: string; testErc1155?: string; + testErc3009?: string; + testErc2612?: string; forwarder: string; seaport?: string; openseaWrapper?: string; + permit2: string; }; export type EnvironmentType = "local" | "testing" | "staging" | "production"; diff --git a/packages/core-sdk/src/erc20/handler.ts b/packages/core-sdk/src/erc20/handler.ts index 246a7f154..4d5462fcc 100644 --- a/packages/core-sdk/src/erc20/handler.ts +++ b/packages/core-sdk/src/erc20/handler.ts @@ -3,8 +3,92 @@ import { TransactionRequest, TransactionResponse } from "@bosonprotocol/common"; +import { defaultAbiCoder } from "@ethersproject/abi"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; +import { hexlify } from "@ethersproject/bytes"; +import { randomBytes } from "@ethersproject/random"; import { erc20Iface } from "./interface"; +import type { ApproveExchangeTokenBaseArgs } from "../native-meta-tx/handler"; +import { alternativeNonceIface } from "../native-meta-tx/interface"; +import { + prepareDataSignatureParameters, + StructuredData +} from "../utils/signature"; + +export type UnsignedTransferAuthorization = + | { + strategy: "ERC3009"; + data: { + validAfter: BigNumberish; + validBefore: BigNumberish; + nonce: string; + }; + } + | { + strategy: "EIP2612"; + data: { deadline: BigNumberish }; + } + | { + strategy: "Permit2"; + data: { nonce: BigNumberish; deadline: BigNumberish }; + }; + +export type TransferAuthorization = UnsignedTransferAuthorization & { + r: string; + s: string; + v: number; + signature: string; +}; + +const TRANSFER_STRATEGY_ID = { + ERC3009: 1, + EIP2612: 2, + Permit2: 3 +} as const; + +function encodeTransferAuthorizationEntry(auth: TransferAuthorization): string { + let innerData: string; + switch (auth.strategy) { + case "ERC3009": + innerData = defaultAbiCoder.encode( + ["uint256", "uint256", "bytes32", "uint8", "bytes32", "bytes32"], + [ + auth.data.validAfter, + auth.data.validBefore, + auth.data.nonce, + auth.v, + auth.r, + auth.s + ] + ); + break; + case "EIP2612": + innerData = defaultAbiCoder.encode( + ["uint256", "uint8", "bytes32", "bytes32"], + [auth.data.deadline, auth.v, auth.r, auth.s] + ); + break; + case "Permit2": + innerData = defaultAbiCoder.encode( + ["uint256", "uint256", "bytes"], + [auth.data.nonce, auth.data.deadline, auth.signature] + ); + break; + } + return defaultAbiCoder.encode( + ["uint8", "bytes"], + [TRANSFER_STRATEGY_ID[auth.strategy], innerData] + ); +} + +export function encodeTransferAuthorizationQueue( + auths: TransferAuthorization[] +): string { + return defaultAbiCoder.encode( + ["bytes[]"], + [auths.map(encodeTransferAuthorizationEntry)] + ); +} // Overload: returnTxInfo is true -> returns TransactionRequest export async function approve(args: { @@ -130,3 +214,273 @@ export async function balanceOf(args: { const [balance] = erc20Iface.decodeFunctionResult("balanceOf", result); return String(balance); } + +type SignReceiveWithErc3009AuthorizationArgs = ApproveExchangeTokenBaseArgs & { + tokenDomain: { name: string; version: string }; + validAfter: BigNumberish; + validBefore: BigNumberish; +}; + +// Overload: returnTypedDataToSign is true → returns StructuredData +export async function signReceiveWithErc3009Authorization( + args: SignReceiveWithErc3009AuthorizationArgs & { + returnTypedDataToSign: true; + } +): Promise; +// Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (ERC3009) +export async function signReceiveWithErc3009Authorization( + args: SignReceiveWithErc3009AuthorizationArgs & { + returnTypedDataToSign?: false | undefined; + } +): Promise; +// Implementation +export async function signReceiveWithErc3009Authorization( + args: SignReceiveWithErc3009AuthorizationArgs & { + returnTypedDataToSign?: boolean; + } +): Promise<(TransferAuthorization & { strategy: "ERC3009" }) | StructuredData> { + const nonce = hexlify(randomBytes(32)); + + const customSignatureType = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ], + ReceiveWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" } + ] + }; + + const customDomainData = { + name: args.tokenDomain.name, + version: args.tokenDomain.version, + chainId: args.chainId, + salt: undefined + }; + + const message = { + from: args.user, + to: args.spender, + value: args.value.toString(), + validAfter: args.validAfter.toString(), + validBefore: args.validBefore.toString(), + nonce + }; + + const baseParams = { + web3Lib: args.web3Lib, + chainId: args.chainId, + verifyingContractAddress: args.exchangeToken, + customSignatureType, + customDomainData, + primaryType: "ReceiveWithAuthorization", + message + }; + + if (args.returnTypedDataToSign) { + return prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: true + }); + } + + const sig = await prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: false + }); + + return { + ...sig, + strategy: "ERC3009", + data: { + validAfter: args.validAfter, + validBefore: args.validBefore, + nonce + } + }; +} + +type SignReceiveWithErc2612PermitArgs = ApproveExchangeTokenBaseArgs & { + tokenDomain: { name: string; version: string }; + deadline: BigNumberish; +}; + +// Overload: returnTypedDataToSign is true → returns StructuredData +export async function signReceiveWithErc2612Permit( + args: SignReceiveWithErc2612PermitArgs & { + returnTypedDataToSign: true; + } +): Promise; +// Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (EIP2612) +export async function signReceiveWithErc2612Permit( + args: SignReceiveWithErc2612PermitArgs & { + returnTypedDataToSign?: false | undefined; + } +): Promise; +// Implementation +export async function signReceiveWithErc2612Permit( + args: SignReceiveWithErc2612PermitArgs & { + returnTypedDataToSign?: boolean; + } +): Promise<(TransferAuthorization & { strategy: "EIP2612" }) | StructuredData> { + const nonceResult = await args.web3Lib.call({ + to: args.exchangeToken, + data: alternativeNonceIface.encodeFunctionData("nonces", [args.user]) + }); + const [nonce] = alternativeNonceIface.decodeFunctionResult( + "nonces", + nonceResult + ); + + const customSignatureType = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ], + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + }; + + const customDomainData = { + name: args.tokenDomain.name, + version: args.tokenDomain.version, + chainId: args.chainId, + salt: undefined + }; + + const message = { + owner: args.user, + spender: args.spender, + value: args.value.toString(), + nonce: nonce.toString(), + deadline: args.deadline.toString() + }; + + const baseParams = { + web3Lib: args.web3Lib, + chainId: args.chainId, + verifyingContractAddress: args.exchangeToken, + customSignatureType, + customDomainData, + primaryType: "Permit", + message + }; + + if (args.returnTypedDataToSign) { + return prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: true + }); + } + + const sig = await prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: false + }); + + return { + ...sig, + strategy: "EIP2612", + data: { deadline: args.deadline } + }; +} + +type SignReceiveWithPermit2Args = ApproveExchangeTokenBaseArgs & { + permit2Address: string; + deadline: BigNumberish; + permit2Nonce?: BigNumberish; +}; + +// Overload: returnTypedDataToSign is true → returns StructuredData +export async function signReceiveWithPermit2( + args: SignReceiveWithPermit2Args & { returnTypedDataToSign: true } +): Promise; +// Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (Permit2) +export async function signReceiveWithPermit2( + args: SignReceiveWithPermit2Args & { + returnTypedDataToSign?: false | undefined; + } +): Promise; +// Implementation +export async function signReceiveWithPermit2( + args: SignReceiveWithPermit2Args & { returnTypedDataToSign?: boolean } +): Promise<(TransferAuthorization & { strategy: "Permit2" }) | StructuredData> { + const permit2Nonce = args.permit2Nonce ?? BigNumber.from(randomBytes(32)); + + const customSignatureType = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ], + PermitTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" } + ] + }; + + const customDomainData = { + name: "Permit2", + chainId: args.chainId, + version: undefined, + salt: undefined + }; + + const message = { + permitted: { + token: args.exchangeToken, + amount: args.value.toString() + }, + spender: args.spender, + nonce: permit2Nonce.toString(), + deadline: args.deadline.toString() + }; + + const baseParams = { + web3Lib: args.web3Lib, + chainId: args.chainId, + verifyingContractAddress: args.permit2Address, + customSignatureType, + customDomainData, + primaryType: "PermitTransferFrom", + message + }; + + if (args.returnTypedDataToSign) { + return prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: true + }); + } + + const sig = await prepareDataSignatureParameters({ + ...baseParams, + returnTypedDataToSign: false + }); + + return { + ...sig, + strategy: "Permit2", + data: { nonce: permit2Nonce, deadline: args.deadline } + }; +} diff --git a/packages/core-sdk/src/erc20/mixin.ts b/packages/core-sdk/src/erc20/mixin.ts index 5dcacb211..9bb3a836d 100644 --- a/packages/core-sdk/src/erc20/mixin.ts +++ b/packages/core-sdk/src/erc20/mixin.ts @@ -3,6 +3,7 @@ import { TransactionResponse, TransactionRequest } from "@bosonprotocol/common"; +import { BigNumberish } from "@ethersproject/bignumber"; import { BaseCoreSDK } from "./../mixins/base-core-sdk"; import { approve, @@ -11,8 +12,13 @@ import { getSymbol, getName, ensureAllowance, - balanceOf + balanceOf, + signReceiveWithErc3009Authorization, + signReceiveWithErc2612Permit, + signReceiveWithPermit2, + TransferAuthorization } from "./handler"; +import { StructuredData } from "../utils/signature"; export class ERC20Mixin extends BaseCoreSDK { /* -------------------------------------------------------------------------- */ @@ -121,4 +127,203 @@ export class ERC20Mixin extends BaseCoreSDK { ): Promise> { return balanceOf({ web3Lib: this._web3Lib, ...args }); } + + /** + * Signs an ERC-3009 `ReceiveWithAuthorization` payload that authorizes the + * spender (default: protocol diamond) to pull `value` units of `exchangeToken` + * from the signer. Returns a `TransferAuthorization` tagged with strategy + * `"ERC3009"`, ready to feed into `relayMetaTransaction` via + * `transferAuthorizations`. + */ + // Overload: returnTypedDataToSign is true → returns StructuredData + public async signReceiveWithErc3009Authorization( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + validAfter: BigNumberish, + validBefore: BigNumberish, + overrides: Partial<{ spender: string }> & { returnTypedDataToSign: true } + ): Promise; + // Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (ERC3009) + public async signReceiveWithErc3009Authorization( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + validAfter: BigNumberish, + validBefore: BigNumberish, + overrides?: Partial<{ spender: string; returnTypedDataToSign?: false }> + ): Promise; + // Implementation + public async signReceiveWithErc3009Authorization( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + validAfter: BigNumberish, + validBefore: BigNumberish, + overrides: Partial<{ + spender: string; + returnTypedDataToSign: boolean; + }> = {} + ): Promise< + (TransferAuthorization & { strategy: "ERC3009" }) | StructuredData + > { + const user = await this._web3Lib.getSignerAddress(); + const baseArgs = { + web3Lib: this._web3Lib, + chainId: this._chainId, + user, + exchangeToken, + spender: overrides.spender || this._protocolDiamond, + value, + tokenDomain, + validAfter, + validBefore + }; + if (overrides.returnTypedDataToSign) { + return signReceiveWithErc3009Authorization({ + ...baseArgs, + returnTypedDataToSign: true + }); + } + return signReceiveWithErc3009Authorization({ + ...baseArgs, + returnTypedDataToSign: false + }); + } + + /** + * Signs an EIP-2612 `Permit` payload that authorizes the spender (default: + * protocol diamond) to pull `value` units of `exchangeToken` from the signer + * up to `deadline`. Returns a `TransferAuthorization` tagged with strategy + * `"EIP2612"`, ready to feed into `relayMetaTransaction` via + * `transferAuthorizations`. + */ + // Overload: returnTypedDataToSign is true → returns StructuredData + public async signReceiveWithErc2612Permit( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + deadline: BigNumberish, + overrides: Partial<{ spender: string }> & { returnTypedDataToSign: true } + ): Promise; + // Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (EIP2612) + public async signReceiveWithErc2612Permit( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + deadline: BigNumberish, + overrides?: Partial<{ spender: string; returnTypedDataToSign?: false }> + ): Promise; + // Implementation + public async signReceiveWithErc2612Permit( + exchangeToken: string, + tokenDomain: { name: string; version: string }, + value: BigNumberish, + deadline: BigNumberish, + overrides: Partial<{ + spender: string; + returnTypedDataToSign: boolean; + }> = {} + ): Promise< + (TransferAuthorization & { strategy: "EIP2612" }) | StructuredData + > { + const user = await this._web3Lib.getSignerAddress(); + const baseArgs = { + web3Lib: this._web3Lib, + chainId: this._chainId, + user, + exchangeToken, + spender: overrides.spender || this._protocolDiamond, + value, + tokenDomain, + deadline + }; + if (overrides.returnTypedDataToSign) { + return signReceiveWithErc2612Permit({ + ...baseArgs, + returnTypedDataToSign: true + }); + } + return signReceiveWithErc2612Permit({ + ...baseArgs, + returnTypedDataToSign: false + }); + } + + /** + * Signs a Uniswap Permit2 `PermitTransferFrom` payload authorizing the + * spender (default: protocol diamond) to pull `value` units of + * `exchangeToken` from the signer up to `deadline`. The Permit2 contract + * address defaults to `contracts.permit2` from SDK config and can be + * overridden via `overrides.permit2Address`. If `overrides.permit2Nonce` + * is omitted, a random uint256 is generated. Returns a `TransferAuthorization` + * tagged with strategy `"Permit2"`, ready to feed into `relayMetaTransaction` + * via `transferAuthorizations`. + */ + // Overload: returnTypedDataToSign is true → returns StructuredData + public async signReceiveWithPermit2( + exchangeToken: string, + value: BigNumberish, + deadline: BigNumberish, + overrides: Partial<{ + spender: string; + permit2Address: string; + permit2Nonce: BigNumberish; + }> & { returnTypedDataToSign: true } + ): Promise; + // Overload: returnTypedDataToSign is false or undefined → returns TransferAuthorization (Permit2) + public async signReceiveWithPermit2( + exchangeToken: string, + value: BigNumberish, + deadline: BigNumberish, + overrides?: Partial<{ + spender: string; + permit2Address: string; + permit2Nonce: BigNumberish; + returnTypedDataToSign?: false; + }> + ): Promise; + // Implementation + public async signReceiveWithPermit2( + exchangeToken: string, + value: BigNumberish, + deadline: BigNumberish, + overrides: Partial<{ + spender: string; + permit2Address: string; + permit2Nonce: BigNumberish; + returnTypedDataToSign: boolean; + }> = {} + ): Promise< + (TransferAuthorization & { strategy: "Permit2" }) | StructuredData + > { + const user = await this._web3Lib.getSignerAddress(); + const permit2Address = overrides.permit2Address || this._contracts?.permit2; + if (!permit2Address) { + throw new Error( + "Permit2 contract address not configured. Provide overrides.permit2Address or initialize CoreSDK with contracts.permit2." + ); + } + const baseArgs = { + web3Lib: this._web3Lib, + chainId: this._chainId, + user, + exchangeToken, + spender: overrides.spender || this._protocolDiamond, + value, + permit2Address, + deadline, + permit2Nonce: overrides.permit2Nonce + }; + if (overrides.returnTypedDataToSign) { + return signReceiveWithPermit2({ + ...baseArgs, + returnTypedDataToSign: true + }); + } + return signReceiveWithPermit2({ + ...baseArgs, + returnTypedDataToSign: false + }); + } } diff --git a/packages/core-sdk/src/meta-tx/handler.ts b/packages/core-sdk/src/meta-tx/handler.ts index 4b95850a2..ac8eee44c 100644 --- a/packages/core-sdk/src/meta-tx/handler.ts +++ b/packages/core-sdk/src/meta-tx/handler.ts @@ -61,6 +61,10 @@ import { import { keccak256 } from "@ethersproject/keccak256"; import { id } from "@ethersproject/hash"; import { defaultAbiCoder } from "@ethersproject/abi"; +import { + TransferAuthorization, + encodeTransferAuthorizationQueue +} from "../erc20/handler"; import { ERC20ForwardRequest } from "../forwarder/biconomy-interface"; import { getNonce, verifyEIP712 } from "../forwarder/handler"; import { MockForwardRequest } from "../forwarder/mock-interface"; @@ -2018,6 +2022,7 @@ export async function relayMetaTransaction(args: { sigR: BytesLike; sigS: BytesLike; sigV: BigNumberish; + transferAuthorizations?: TransferAuthorization[]; }; }; }): Promise { @@ -2029,19 +2034,27 @@ export async function relayMetaTransaction(args: { metaTx.config.apiId ); + const baseParams: unknown[] = [ + metaTx.params.userAddress, + metaTx.params.functionName, + metaTx.params.functionSignature, + metaTx.params.nonce, + rebuildSignature({ + r: metaTx.params.sigR.toString(), + s: metaTx.params.sigS.toString(), + v: Number(metaTx.params.sigV) + }) + ]; + const params = metaTx.params.transferAuthorizations?.length + ? [ + ...baseParams, + encodeTransferAuthorizationQueue(metaTx.params.transferAuthorizations) + ] + : baseParams; + const relayTxResponse = await biconomy.relayTransaction({ to: contractAddress, - params: [ - metaTx.params.userAddress, - metaTx.params.functionName, - metaTx.params.functionSignature, - metaTx.params.nonce, - rebuildSignature({ - r: metaTx.params.sigR.toString(), - s: metaTx.params.sigS.toString(), - v: Number(metaTx.params.sigV) - }) - ], + params, from: metaTx.params.userAddress }); diff --git a/packages/core-sdk/src/meta-tx/mixin.ts b/packages/core-sdk/src/meta-tx/mixin.ts index 64624ac78..8209ac726 100644 --- a/packages/core-sdk/src/meta-tx/mixin.ts +++ b/packages/core-sdk/src/meta-tx/mixin.ts @@ -14,6 +14,7 @@ import { accounts } from ".."; import { AccountsMixin } from "../accounts/mixin"; import { SellerFieldsFragment } from "../subgraph"; import { SignedMetaTx, UnsignedMetaTx } from "./handler"; +import { TransferAuthorization } from "../erc20/handler"; export class MetaTxMixin extends BaseCoreSDK { /* -------------------------------------------------------------------------- */ /* Meta Tx related methods */ @@ -1672,6 +1673,7 @@ export class MetaTxMixin extends BaseCoreSDK { sigR: BytesLike; sigS: BytesLike; sigV: BigNumberish; + transferAuthorizations?: TransferAuthorization[]; }, overrides: Partial<{ userAddress: string; @@ -1701,7 +1703,8 @@ export class MetaTxMixin extends BaseCoreSDK { nonce: metaTxParams.nonce, sigR: metaTxParams.sigR, sigS: metaTxParams.sigS, - sigV: metaTxParams.sigV + sigV: metaTxParams.sigV, + transferAuthorizations: metaTxParams.transferAuthorizations } } }); diff --git a/packages/core-sdk/src/native-meta-tx/handler.ts b/packages/core-sdk/src/native-meta-tx/handler.ts index a38bcf4a2..a06abe614 100644 --- a/packages/core-sdk/src/native-meta-tx/handler.ts +++ b/packages/core-sdk/src/native-meta-tx/handler.ts @@ -128,7 +128,7 @@ export async function signNativeMetaTx( }; } -type ApproveExchangeTokenBaseArgs = { +export type ApproveExchangeTokenBaseArgs = { web3Lib: Web3LibAdapter; chainId: number; user: string; diff --git a/packages/core-sdk/src/utils/signature.ts b/packages/core-sdk/src/utils/signature.ts index 19c7a55f0..ac1ec5deb 100644 --- a/packages/core-sdk/src/utils/signature.ts +++ b/packages/core-sdk/src/utils/signature.ts @@ -20,12 +20,24 @@ export type StructuredData = { type: string; }[]; }; - domain: { - name: string; - version: string; - verifyingContract: string; - salt: string; - }; + domain: + | { + name: string; + version: string; + verifyingContract: string; + salt: string; + } + | { + name: string; + version: string; + verifyingContract: string; + chainId: number | string; + } + | { + name: string; + verifyingContract: string; + chainId: number | string; + }; primaryType: string; message: Record; }; diff --git a/packages/core-sdk/tests/erc20/handler.test.ts b/packages/core-sdk/tests/erc20/handler.test.ts new file mode 100644 index 000000000..8274ed1bc --- /dev/null +++ b/packages/core-sdk/tests/erc20/handler.test.ts @@ -0,0 +1,424 @@ +import { MockWeb3LibAdapter } from "@bosonprotocol/common/tests/mocks"; +import { defaultAbiCoder } from "@ethersproject/abi"; +import { MaxUint256 } from "@ethersproject/constants"; +import { BigNumber } from "@ethersproject/bignumber"; +import { + signReceiveWithErc3009Authorization, + signReceiveWithErc2612Permit, + signReceiveWithPermit2, + encodeTransferAuthorizationQueue, + TransferAuthorization +} from "../../src/erc20/handler"; +import { StructuredData } from "../../src/utils/signature"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CHAIN_ID = 31337; +const EXCHANGE_TOKEN = "0x0000000000000000000000000000000000000010"; +const SPENDER = "0x0000000000000000000000000000000000000011"; +const USER = "0x0000000000000000000000000000000000000012"; +const VALUE = "1000000000000000000"; +const VALID_AFTER = "0"; +const VALID_BEFORE = MaxUint256.toString(); +const TOKEN_DOMAIN = { name: "ERC3009Token", version: "1" }; +// Real-looking ECDSA signature from MockWeb3LibAdapter.send() +const MOCK_SIG = + "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b"; +const EXPECTED_R = + "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd"; +const EXPECTED_S = + "0x72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a"; +const EXPECTED_V = 27; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeWeb3Lib() { + return new MockWeb3LibAdapter({ + getSignerAddress: USER, + send: MOCK_SIG + }); +} + +function baseArgs() { + return { + web3Lib: makeWeb3Lib(), + chainId: CHAIN_ID, + user: USER, + exchangeToken: EXCHANGE_TOKEN, + spender: SPENDER, + value: VALUE, + tokenDomain: TOKEN_DOMAIN, + validAfter: VALID_AFTER, + validBefore: VALID_BEFORE + }; +} + +function decodeQueueSingleEntry(encodedQueue: string): { + strategyId: number; + innerData: string; +} { + const [entries] = defaultAbiCoder.decode(["bytes[]"], encodedQueue); + expect(Array.isArray(entries)).toBe(true); + expect((entries as string[]).length).toBe(1); + const [strategyId, innerData] = defaultAbiCoder.decode( + ["uint8", "bytes"], + (entries as string[])[0] + ); + return { strategyId: Number(strategyId), innerData: innerData as string }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("signReceiveWithErc3009Authorization()", () => { + test("returns a TransferAuthorization tagged ERC3009 when returnTypedDataToSign is omitted", async () => { + const result = await signReceiveWithErc3009Authorization(baseArgs()); + expect(result.r).toBe(EXPECTED_R); + expect(result.s).toBe(EXPECTED_S); + expect(result.v).toBe(EXPECTED_V); + expect(result.signature).toBe(MOCK_SIG); + expect(result.strategy).toBe("ERC3009"); + expect(result.data.validAfter).toBe(VALID_AFTER); + expect(result.data.validBefore).toBe(VALID_BEFORE); + expect(typeof result.data.nonce).toBe("string"); + expect(result.data.nonce.startsWith("0x")).toBe(true); + expect(result.data.nonce.length).toBe(2 + 64); + expect( + (result as unknown as { abiData?: unknown }).abiData + ).toBeUndefined(); + }); + + test("returns a TransferAuthorization tagged ERC3009 when returnTypedDataToSign: false", async () => { + const result = await signReceiveWithErc3009Authorization({ + ...baseArgs(), + returnTypedDataToSign: false + }); + expect(result.r).toBe(EXPECTED_R); + expect(result.s).toBe(EXPECTED_S); + expect(result.v).toBe(EXPECTED_V); + expect(result.strategy).toBe("ERC3009"); + }); + + test("encodeTransferAuthorizationQueue produces a strategy-1 entry whose inner data decodes to [validAfter, validBefore, nonce, v, r, s]", async () => { + const result = await signReceiveWithErc3009Authorization(baseArgs()); + const encoded = encodeTransferAuthorizationQueue([result]); + const { strategyId, innerData } = decodeQueueSingleEntry(encoded); + expect(strategyId).toBe(1); // ERC3009 + const [validAfter, validBefore, nonce, v, r, s] = defaultAbiCoder.decode( + ["uint256", "uint256", "bytes32", "uint8", "bytes32", "bytes32"], + innerData + ); + expect(validAfter.toString()).toBe(VALID_AFTER); + expect(validBefore.toString()).toBe(VALID_BEFORE); + expect(nonce).toBe(result.data.nonce); + expect(Number(v)).toBe(EXPECTED_V); + expect(r).toBe(EXPECTED_R); + expect(s).toBe(EXPECTED_S); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + const result = await signReceiveWithErc3009Authorization({ + ...baseArgs(), + returnTypedDataToSign: true + }); + const data = result as StructuredData; + expect(data.primaryType).toBe("ReceiveWithAuthorization"); + expect(data.domain.verifyingContract).toBe(EXCHANGE_TOKEN); + expect(data.domain.name).toBe(TOKEN_DOMAIN.name); + expect((data.domain as { version?: string }).version).toBe( + TOKEN_DOMAIN.version + ); + // chainId-form domain, NOT salt-form + expect((data.domain as { chainId?: number | string }).chainId).toBe( + CHAIN_ID + ); + expect((data.domain as { salt?: string }).salt).toBeUndefined(); + // EIP712Domain type must declare chainId, not salt + const domainTypeNames = data.types.EIP712Domain.map((t) => t.name); + expect(domainTypeNames).toContain("chainId"); + expect(domainTypeNames).not.toContain("salt"); + // Message fields + expect(data.message.from).toBe(USER); + expect(data.message.to).toBe(SPENDER); + expect(data.message.value).toBe(VALUE); + expect(data.message.validAfter).toBe(VALID_AFTER); + expect(data.message.validBefore).toBe(VALID_BEFORE); + // nonce is a 0x-prefixed 32-byte hex string + const nonce = data.message.nonce as string; + expect(typeof nonce).toBe("string"); + expect(nonce.startsWith("0x")).toBe(true); + expect(nonce.length).toBe(2 + 64); + // Must NOT look like a TransferAuthorization + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + expect( + (data as unknown as { strategy?: unknown }).strategy + ).toBeUndefined(); + }); + + test("each call produces a fresh random nonce", async () => { + const a = await signReceiveWithErc3009Authorization({ + ...baseArgs(), + returnTypedDataToSign: true + }); + const b = await signReceiveWithErc3009Authorization({ + ...baseArgs(), + returnTypedDataToSign: true + }); + expect(a.message.nonce).not.toBe(b.message.nonce); + }); +}); + +// ─── EIP-2612 tests ─────────────────────────────────────────────────────────── + +const DEADLINE = MaxUint256.toString(); +const ERC2612_TOKEN_DOMAIN = { name: "ERC2612Token", version: "1" }; +// ABI-encoded uint256(1) — returned as the on-chain `nonces(owner)` value. +const ABI_UINT256_ONE = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + +function makeWeb3LibForPermit() { + return new MockWeb3LibAdapter({ + getSignerAddress: USER, + send: MOCK_SIG, + call: ABI_UINT256_ONE + }); +} + +function permitBaseArgs() { + return { + web3Lib: makeWeb3LibForPermit(), + chainId: CHAIN_ID, + user: USER, + exchangeToken: EXCHANGE_TOKEN, + spender: SPENDER, + value: VALUE, + tokenDomain: ERC2612_TOKEN_DOMAIN, + deadline: DEADLINE + }; +} + +describe("signReceiveWithErc2612Permit()", () => { + test("returns a TransferAuthorization tagged EIP2612 when returnTypedDataToSign is omitted", async () => { + const result = await signReceiveWithErc2612Permit(permitBaseArgs()); + expect(result.r).toBe(EXPECTED_R); + expect(result.s).toBe(EXPECTED_S); + expect(result.v).toBe(EXPECTED_V); + expect(result.signature).toBe(MOCK_SIG); + expect(result.strategy).toBe("EIP2612"); + expect(result.data.deadline).toBe(DEADLINE); + expect( + (result as unknown as { abiData?: unknown }).abiData + ).toBeUndefined(); + }); + + test("returns a TransferAuthorization tagged EIP2612 when returnTypedDataToSign: false", async () => { + const result = await signReceiveWithErc2612Permit({ + ...permitBaseArgs(), + returnTypedDataToSign: false + }); + expect(result.r).toBe(EXPECTED_R); + expect(result.strategy).toBe("EIP2612"); + expect(result.data.deadline).toBe(DEADLINE); + }); + + test("encodeTransferAuthorizationQueue produces a strategy-2 entry whose inner data decodes to [deadline, v, r, s]", async () => { + const result = await signReceiveWithErc2612Permit(permitBaseArgs()); + const encoded = encodeTransferAuthorizationQueue([result]); + const { strategyId, innerData } = decodeQueueSingleEntry(encoded); + expect(strategyId).toBe(2); // EIP2612 + const [deadline, v, r, s] = defaultAbiCoder.decode( + ["uint256", "uint8", "bytes32", "bytes32"], + innerData + ); + expect(deadline.toString()).toBe(DEADLINE); + expect(Number(v)).toBe(EXPECTED_V); + expect(r).toBe(EXPECTED_R); + expect(s).toBe(EXPECTED_S); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + const result = await signReceiveWithErc2612Permit({ + ...permitBaseArgs(), + returnTypedDataToSign: true + }); + const data = result as StructuredData; + expect(data.primaryType).toBe("Permit"); + expect(data.domain.verifyingContract).toBe(EXCHANGE_TOKEN); + expect(data.domain.name).toBe(ERC2612_TOKEN_DOMAIN.name); + expect((data.domain as { version?: string }).version).toBe( + ERC2612_TOKEN_DOMAIN.version + ); + expect((data.domain as { chainId?: number | string }).chainId).toBe( + CHAIN_ID + ); + expect((data.domain as { salt?: string }).salt).toBeUndefined(); + const domainTypeNames = data.types.EIP712Domain.map((t) => t.name); + expect(domainTypeNames).toContain("chainId"); + expect(domainTypeNames).not.toContain("salt"); + // Message fields use the EIP-2612 schema. + expect(data.message.owner).toBe(USER); + expect(data.message.spender).toBe(SPENDER); + expect(data.message.value).toBe(VALUE); + expect(data.message.nonce).toBe("1"); + expect(data.message.deadline).toBe(DEADLINE); + // Must NOT look like a TransferAuthorization + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + expect( + (data as unknown as { strategy?: unknown }).strategy + ).toBeUndefined(); + }); + + test("reads nonce from the token contract via nonces(owner)", async () => { + const ABI_UINT256_SEVEN = + "0x0000000000000000000000000000000000000000000000000000000000000007"; + const web3Lib = new MockWeb3LibAdapter({ + getSignerAddress: USER, + send: MOCK_SIG, + call: ABI_UINT256_SEVEN + }); + const data = await signReceiveWithErc2612Permit({ + ...permitBaseArgs(), + web3Lib, + returnTypedDataToSign: true + }); + expect(data.message.nonce).toBe("7"); + }); +}); + +// ─── Permit2 tests ──────────────────────────────────────────────────────────── + +const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +const PERMIT2_NONCE = "42"; + +function permit2BaseArgs() { + return { + web3Lib: new MockWeb3LibAdapter({ + getSignerAddress: USER, + send: MOCK_SIG + }), + chainId: CHAIN_ID, + user: USER, + exchangeToken: EXCHANGE_TOKEN, + spender: SPENDER, + value: VALUE, + permit2Address: PERMIT2_ADDRESS, + deadline: DEADLINE, + permit2Nonce: PERMIT2_NONCE + }; +} + +describe("signReceiveWithPermit2()", () => { + test("returns a TransferAuthorization tagged Permit2 when returnTypedDataToSign is omitted", async () => { + const result = await signReceiveWithPermit2(permit2BaseArgs()); + expect(result.r).toBe(EXPECTED_R); + expect(result.s).toBe(EXPECTED_S); + expect(result.v).toBe(EXPECTED_V); + expect(result.signature).toBe(MOCK_SIG); + expect(result.strategy).toBe("Permit2"); + expect(result.data.nonce.toString()).toBe(PERMIT2_NONCE); + expect(result.data.deadline).toBe(DEADLINE); + expect( + (result as unknown as { abiData?: unknown }).abiData + ).toBeUndefined(); + }); + + test("returns a TransferAuthorization tagged Permit2 when returnTypedDataToSign: false", async () => { + const result = await signReceiveWithPermit2({ + ...permit2BaseArgs(), + returnTypedDataToSign: false + }); + expect(result.r).toBe(EXPECTED_R); + expect(result.strategy).toBe("Permit2"); + expect(result.data.nonce.toString()).toBe(PERMIT2_NONCE); + }); + + test("encodeTransferAuthorizationQueue produces a strategy-3 entry whose inner data decodes to [permit2Nonce, deadline, signature]", async () => { + const result = await signReceiveWithPermit2(permit2BaseArgs()); + const encoded = encodeTransferAuthorizationQueue([result]); + const { strategyId, innerData } = decodeQueueSingleEntry(encoded); + expect(strategyId).toBe(3); // Permit2 + const [nonce, deadline, signature] = defaultAbiCoder.decode( + ["uint256", "uint256", "bytes"], + innerData + ); + expect(nonce.toString()).toBe(PERMIT2_NONCE); + expect(deadline.toString()).toBe(DEADLINE); + expect(signature).toBe(MOCK_SIG); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + const result = await signReceiveWithPermit2({ + ...permit2BaseArgs(), + returnTypedDataToSign: true + }); + const data = result as StructuredData; + expect(data.primaryType).toBe("PermitTransferFrom"); + // 3-field Permit2 domain — name + chainId + verifyingContract, NO version, NO salt. + expect(data.domain.name).toBe("Permit2"); + expect(data.domain.verifyingContract).toBe(PERMIT2_ADDRESS); + expect((data.domain as { chainId?: number | string }).chainId).toBe( + CHAIN_ID + ); + expect((data.domain as { version?: string }).version).toBeUndefined(); + expect((data.domain as { salt?: string }).salt).toBeUndefined(); + const domainTypeNames = data.types.EIP712Domain.map((t) => t.name); + expect(domainTypeNames).toEqual(["name", "chainId", "verifyingContract"]); + // Message fields (note: token + amount nested under `permitted`). + const permitted = data.message.permitted as { + token: string; + amount: string; + }; + expect(permitted.token).toBe(EXCHANGE_TOKEN); + expect(permitted.amount).toBe(VALUE); + expect(data.message.spender).toBe(SPENDER); + expect(data.message.nonce).toBe(PERMIT2_NONCE); + expect(data.message.deadline).toBe(DEADLINE); + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + expect( + (data as unknown as { strategy?: unknown }).strategy + ).toBeUndefined(); + }); + + test("generates a random uint256 nonce when permit2Nonce is omitted", async () => { + const args = permit2BaseArgs(); + delete (args as { permit2Nonce?: unknown }).permit2Nonce; + const a = await signReceiveWithPermit2({ + ...args, + returnTypedDataToSign: true + }); + const b = await signReceiveWithPermit2({ + ...args, + returnTypedDataToSign: true + }); + const nonceA = a.message.nonce as string; + const nonceB = b.message.nonce as string; + expect(nonceA).not.toBe(nonceB); + // Each value is within uint256 range. + expect(BigNumber.from(nonceA).gte(0)).toBe(true); + expect(BigNumber.from(nonceB).gte(0)).toBe(true); + }); +}); + +// ─── encodeTransferAuthorizationQueue tests ────────────────────────────────── + +describe("encodeTransferAuthorizationQueue()", () => { + test("encodes an empty queue as an empty bytes[]", () => { + const encoded = encodeTransferAuthorizationQueue([]); + const [entries] = defaultAbiCoder.decode(["bytes[]"], encoded); + expect((entries as string[]).length).toBe(0); + }); + + test("encodes a multi-strategy queue preserving order", async () => { + const erc3009 = await signReceiveWithErc3009Authorization(baseArgs()); + const eip2612 = await signReceiveWithErc2612Permit(permitBaseArgs()); + const permit2 = await signReceiveWithPermit2(permit2BaseArgs()); + const queue: TransferAuthorization[] = [erc3009, eip2612, permit2]; + const encoded = encodeTransferAuthorizationQueue(queue); + const [entries] = defaultAbiCoder.decode(["bytes[]"], encoded); + expect((entries as string[]).length).toBe(3); + const decodedIds = (entries as string[]).map((entry) => { + const [id] = defaultAbiCoder.decode(["uint8", "bytes"], entry); + return Number(id); + }); + expect(decodedIds).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/core-sdk/tests/erc20/mixin.test.ts b/packages/core-sdk/tests/erc20/mixin.test.ts new file mode 100644 index 000000000..4dbbee10d --- /dev/null +++ b/packages/core-sdk/tests/erc20/mixin.test.ts @@ -0,0 +1,589 @@ +import { abis } from "@bosonprotocol/common"; +import { MockWeb3LibAdapter } from "@bosonprotocol/common/tests/mocks"; +import { MaxUint256 } from "@ethersproject/constants"; +import * as erc20Handler from "../../src/erc20/handler"; +import { CoreSDK } from "../../src/core-sdk"; +import { StructuredData } from "../../src/utils/signature"; +import { BICONOMY_URL, SUBGRAPH_URL } from "../mocks"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CHAIN_ID = 31337; +const PROTOCOL_DIAMOND = "0x0000000000000000000000000000000000000001"; +const PRICE_DISCOVERY = "0x0000000000000000000000000000000000000002"; +const FORWARDER = "0x0000000000000000000000000000000000000003"; +const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +const SIGNER = "0x0000000000000000000000000000000000000004"; +const EXCHANGE_TOKEN = "0x0000000000000000000000000000000000000010"; +const CUSTOM_SPENDER = "0x0000000000000000000000000000000000000011"; +const VALUE = "1000000000000000000"; +const VALID_AFTER = "0"; +const VALID_BEFORE = MaxUint256.toString(); +const TOKEN_DOMAIN = { name: "ERC3009Token", version: "1" }; +const MOCK_SIG = + "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b"; + +// ─── Factory ────────────────────────────────────────────────────────────────── + +function makeCoreSDK() { + return new CoreSDK({ + web3Lib: new MockWeb3LibAdapter({ + getSignerAddress: SIGNER, + send: MOCK_SIG + }), + subgraphUrl: SUBGRAPH_URL, + protocolDiamond: PROTOCOL_DIAMOND, + chainId: CHAIN_ID, + metaTx: { + relayerUrl: BICONOMY_URL, + apiKey: "test-api-key", + apiIds: { + [PROTOCOL_DIAMOND.toLowerCase()]: { + executeMetaTransaction: "test-api-id" + } + }, + forwarderAbi: abis.MockForwarderABI + }, + contracts: { + protocolDiamond: PROTOCOL_DIAMOND, + priceDiscoveryClient: PRICE_DISCOVERY, + forwarder: FORWARDER, + permit2: PERMIT2 + } + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ERC20Mixin#signReceiveWithErc3009Authorization()", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── overload dispatch ────────────────────────────────────────────────────── + + test("returns a TransferAuthorization tagged ERC3009 when overrides is omitted", async () => { + const result = await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE + ); + expect(typeof result.r).toBe("string"); + expect(typeof result.s).toBe("string"); + expect(typeof result.v).toBe("number"); + expect(typeof result.signature).toBe("string"); + expect(result.strategy).toBe("ERC3009"); + expect(result.data.validAfter).toBe(VALID_AFTER); + expect(result.data.validBefore).toBe(VALID_BEFORE); + expect(typeof result.data.nonce).toBe("string"); + }); + + test("returns a TransferAuthorization tagged ERC3009 when returnTypedDataToSign: false", async () => { + const result = await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE, + { returnTypedDataToSign: false } + ); + expect(result.strategy).toBe("ERC3009"); + expect(result.data.validAfter).toBe(VALID_AFTER); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await ( + makeCoreSDK().signReceiveWithErc3009Authorization as any + )(EXCHANGE_TOKEN, TOKEN_DOMAIN, VALUE, VALID_AFTER, VALID_BEFORE, { + returnTypedDataToSign: true + }); + const data = result as StructuredData; + expect(data.primaryType).toBe("ReceiveWithAuthorization"); + expect(data.domain.verifyingContract).toBe(EXCHANGE_TOKEN); + expect(data.domain.name).toBe(TOKEN_DOMAIN.name); + expect((data.domain as { chainId?: number }).chainId).toBe(CHAIN_ID); + expect((data.domain as { salt?: string }).salt).toBeUndefined(); + expect(data.message.from).toBe(SIGNER); + expect(data.message.to).toBe(PROTOCOL_DIAMOND); + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + }); + + // ── argument injection ───────────────────────────────────────────────────── + + test("defaults spender to protocolDiamond when not provided", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc3009Authorization") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: PROTOCOL_DIAMOND }) + ); + }); + + test("uses the provided spender override", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc3009Authorization") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE, + { spender: CUSTOM_SPENDER } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: CUSTOM_SPENDER }) + ); + }); + + test("injects the signer address as user", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc3009Authorization") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE + ); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ user: SIGNER })); + }); + + test("passes returnTypedDataToSign: true through to the handler", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc3009Authorization") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (makeCoreSDK().signReceiveWithErc3009Authorization as any)( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE, + { returnTypedDataToSign: true } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ returnTypedDataToSign: true }) + ); + }); + + test("forwards tokenDomain, validAfter and validBefore to the handler", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc3009Authorization") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDK().signReceiveWithErc3009Authorization( + EXCHANGE_TOKEN, + TOKEN_DOMAIN, + VALUE, + VALID_AFTER, + VALID_BEFORE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDomain: TOKEN_DOMAIN, + validAfter: VALID_AFTER, + validBefore: VALID_BEFORE, + exchangeToken: EXCHANGE_TOKEN, + value: VALUE, + chainId: CHAIN_ID + }) + ); + }); +}); + +// ─── EIP-2612 mixin tests ───────────────────────────────────────────────────── + +const DEADLINE = MaxUint256.toString(); +const ERC2612_TOKEN_DOMAIN = { name: "ERC2612Token", version: "1" }; +const ABI_UINT256_ONE = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + +function makeCoreSDKForPermit() { + return new CoreSDK({ + web3Lib: new MockWeb3LibAdapter({ + getSignerAddress: SIGNER, + send: MOCK_SIG, + call: ABI_UINT256_ONE + }), + subgraphUrl: SUBGRAPH_URL, + protocolDiamond: PROTOCOL_DIAMOND, + chainId: CHAIN_ID, + metaTx: { + relayerUrl: BICONOMY_URL, + apiKey: "test-api-key", + apiIds: { + [PROTOCOL_DIAMOND.toLowerCase()]: { + executeMetaTransaction: "test-api-id" + } + }, + forwarderAbi: abis.MockForwarderABI + }, + contracts: { + protocolDiamond: PROTOCOL_DIAMOND, + priceDiscoveryClient: PRICE_DISCOVERY, + forwarder: FORWARDER, + permit2: PERMIT2 + } + }); +} + +describe("ERC20Mixin#signReceiveWithErc2612Permit()", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── overload dispatch ────────────────────────────────────────────────────── + + test("returns a TransferAuthorization tagged EIP2612 when overrides is omitted", async () => { + const result = await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE + ); + expect(typeof result.r).toBe("string"); + expect(typeof result.s).toBe("string"); + expect(typeof result.v).toBe("number"); + expect(typeof result.signature).toBe("string"); + expect(result.strategy).toBe("EIP2612"); + expect(result.data.deadline).toBe(DEADLINE); + }); + + test("returns a TransferAuthorization tagged EIP2612 when returnTypedDataToSign: false", async () => { + const result = await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE, + { returnTypedDataToSign: false } + ); + expect(result.strategy).toBe("EIP2612"); + expect(result.data.deadline).toBe(DEADLINE); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await ( + makeCoreSDKForPermit().signReceiveWithErc2612Permit as any + )(EXCHANGE_TOKEN, ERC2612_TOKEN_DOMAIN, VALUE, DEADLINE, { + returnTypedDataToSign: true + }); + const data = result as StructuredData; + expect(data.primaryType).toBe("Permit"); + expect(data.domain.verifyingContract).toBe(EXCHANGE_TOKEN); + expect(data.domain.name).toBe(ERC2612_TOKEN_DOMAIN.name); + expect((data.domain as { chainId?: number }).chainId).toBe(CHAIN_ID); + expect((data.domain as { salt?: string }).salt).toBeUndefined(); + expect(data.message.owner).toBe(SIGNER); + expect(data.message.spender).toBe(PROTOCOL_DIAMOND); + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + }); + + // ── argument injection ───────────────────────────────────────────────────── + + test("defaults spender to protocolDiamond when not provided", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc2612Permit") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: PROTOCOL_DIAMOND }) + ); + }); + + test("uses the provided spender override", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc2612Permit") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE, + { spender: CUSTOM_SPENDER } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: CUSTOM_SPENDER }) + ); + }); + + test("injects the signer address as user", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc2612Permit") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE + ); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ user: SIGNER })); + }); + + test("passes returnTypedDataToSign: true through to the handler", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc2612Permit") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (makeCoreSDKForPermit().signReceiveWithErc2612Permit as any)( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE, + { returnTypedDataToSign: true } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ returnTypedDataToSign: true }) + ); + }); + + test("forwards tokenDomain and deadline to the handler", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithErc2612Permit") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit().signReceiveWithErc2612Permit( + EXCHANGE_TOKEN, + ERC2612_TOKEN_DOMAIN, + VALUE, + DEADLINE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDomain: ERC2612_TOKEN_DOMAIN, + deadline: DEADLINE, + exchangeToken: EXCHANGE_TOKEN, + value: VALUE, + chainId: CHAIN_ID + }) + ); + }); +}); + +// ─── Permit2 mixin tests ────────────────────────────────────────────────────── + +const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +const PERMIT2_OVERRIDE = "0x000000000000000000000000000000000000ABCD"; +const PERMIT2_NONCE = "42"; + +function makeCoreSDKForPermit2(opts: { withPermit2?: boolean } = {}) { + return new CoreSDK({ + web3Lib: new MockWeb3LibAdapter({ + getSignerAddress: SIGNER, + send: MOCK_SIG + }), + subgraphUrl: SUBGRAPH_URL, + protocolDiamond: PROTOCOL_DIAMOND, + chainId: CHAIN_ID, + metaTx: { + relayerUrl: BICONOMY_URL, + apiKey: "test-api-key", + apiIds: { + [PROTOCOL_DIAMOND.toLowerCase()]: { + executeMetaTransaction: "test-api-id" + } + }, + forwarderAbi: abis.MockForwarderABI + }, + contracts: + opts.withPermit2 === false + ? undefined + : { + protocolDiamond: PROTOCOL_DIAMOND, + priceDiscoveryClient: PRICE_DISCOVERY, + forwarder: FORWARDER, + permit2: PERMIT2_ADDRESS + } + }); +} + +describe("ERC20Mixin#signReceiveWithPermit2()", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── overload dispatch ────────────────────────────────────────────────────── + + test("returns a TransferAuthorization tagged Permit2 when overrides is omitted", async () => { + const result = await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE + ); + expect(typeof result.r).toBe("string"); + expect(typeof result.s).toBe("string"); + expect(typeof result.v).toBe("number"); + expect(typeof result.signature).toBe("string"); + expect(result.strategy).toBe("Permit2"); + expect(result.data.deadline).toBe(DEADLINE); + }); + + test("returns a TransferAuthorization tagged Permit2 when returnTypedDataToSign: false", async () => { + const result = await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE, + { returnTypedDataToSign: false } + ); + expect(result.strategy).toBe("Permit2"); + expect(result.data.deadline).toBe(DEADLINE); + }); + + test("returns StructuredData when returnTypedDataToSign: true", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await ( + makeCoreSDKForPermit2().signReceiveWithPermit2 as any + )(EXCHANGE_TOKEN, VALUE, DEADLINE, { returnTypedDataToSign: true }); + const data = result as StructuredData; + expect(data.primaryType).toBe("PermitTransferFrom"); + expect(data.domain.name).toBe("Permit2"); + expect(data.domain.verifyingContract).toBe(PERMIT2_ADDRESS); + expect((data.domain as { version?: string }).version).toBeUndefined(); + expect((data as unknown as { r?: unknown }).r).toBeUndefined(); + }); + + // ── argument injection ───────────────────────────────────────────────────── + + test("defaults permit2Address to _contracts.permit2", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithPermit2") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ permit2Address: PERMIT2_ADDRESS }) + ); + }); + + test("uses the provided permit2Address override", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithPermit2") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE, + { permit2Address: PERMIT2_OVERRIDE } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ permit2Address: PERMIT2_OVERRIDE }) + ); + }); + + test("throws when neither config nor override supplies permit2Address", async () => { + await expect( + makeCoreSDKForPermit2({ withPermit2: false }).signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE + ) + ).rejects.toThrow(/Permit2 contract address not configured/); + }); + + test("defaults spender to protocolDiamond when not provided", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithPermit2") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: PROTOCOL_DIAMOND }) + ); + }); + + test("uses the provided spender override", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithPermit2") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE, + { spender: CUSTOM_SPENDER } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ spender: CUSTOM_SPENDER }) + ); + }); + + test("forwards permit2Nonce when supplied", async () => { + const spy = jest + .spyOn(erc20Handler, "signReceiveWithPermit2") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({} as any); + + await makeCoreSDKForPermit2().signReceiveWithPermit2( + EXCHANGE_TOKEN, + VALUE, + DEADLINE, + { permit2Nonce: PERMIT2_NONCE } + ); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ permit2Nonce: PERMIT2_NONCE }) + ); + }); +}); diff --git a/packages/core-sdk/tests/meta-tx/handler.test.ts b/packages/core-sdk/tests/meta-tx/handler.test.ts index 3a30f9e18..f3a482689 100644 --- a/packages/core-sdk/tests/meta-tx/handler.test.ts +++ b/packages/core-sdk/tests/meta-tx/handler.test.ts @@ -35,8 +35,13 @@ import { signMetaTxExtendDisputeTimeout, signMetaTxWithdrawFunds, signMetaTxDepositFunds, - getResubmitted + getResubmitted, + relayMetaTransaction } from "../../src/meta-tx/handler"; +import { + encodeTransferAuthorizationQueue, + TransferAuthorization +} from "../../src/erc20/handler"; import * as mockInterface from "../../src/forwarder/mock-interface"; import { UnsignedMetaTx } from "../../src/meta-tx/handler"; import nock from "nock"; @@ -896,6 +901,128 @@ describe("meta-tx handler", () => { }); }); + // ── relayMetaTransaction ─────────────────────────────────────────────────── + describe("#relayMetaTransaction()", () => { + const biconomyUrl = "https://api.biconomy.io"; + const CONTRACT = "0x0000000000000000000000000000000000000099"; + const USER_ADDRESS = "0x0000000000000000000000000000000000000088"; + const TX_HASH = + "0xabcdef0000000000000000000000000000000000000000000000000000000001"; + + const baseRelayArgs = () => ({ + web3LibAdapter: new MockWeb3LibAdapter({ + getSignerAddress: USER_ADDRESS, + send: MOCK_SIG, + getChainId: 1 + }), + chainId: 1, + contractAddress: CONTRACT, + metaTx: { + config: { + relayerUrl: biconomyUrl, + apiId: "test-api-id", + apiKey: "test-api-key" + }, + params: { + userAddress: USER_ADDRESS, + functionName: "executeMetaTransaction(...)", + functionSignature: "0xdeadbeef", + nonce: 1, + sigR: EXPECTED_R, + sigS: EXPECTED_S, + sigV: EXPECTED_V + } + } + }); + + test("omits the auth queue when transferAuthorizations is undefined", async () => { + let capturedBody: { params: unknown[] } | undefined; + nock(biconomyUrl) + .post("/api/v2/meta-tx/native", (body) => { + capturedBody = body as { params: unknown[] }; + return true; + }) + .reply(200, { txHash: TX_HASH, log: "", retryDuration: 0, flag: 144 }); + + await relayMetaTransaction(baseRelayArgs()); + + expect(capturedBody?.params).toBeDefined(); + expect((capturedBody as { params: unknown[] }).params.length).toBe(5); + }); + + test("appends the encoded auth queue when transferAuthorizations is provided", async () => { + const authorizations: TransferAuthorization[] = [ + { + strategy: "ERC3009", + data: { + validAfter: "0", + validBefore: "1", + nonce: + "0x1111111111111111111111111111111111111111111111111111111111111111" + }, + r: EXPECTED_R, + s: EXPECTED_S, + v: EXPECTED_V, + signature: MOCK_SIG + }, + { + strategy: "Permit2", + data: { nonce: "42", deadline: "9999999999" }, + r: EXPECTED_R, + s: EXPECTED_S, + v: EXPECTED_V, + signature: MOCK_SIG + } + ]; + + let capturedBody: { params: unknown[] } | undefined; + nock(biconomyUrl) + .post("/api/v2/meta-tx/native", (body) => { + capturedBody = body as { params: unknown[] }; + return true; + }) + .reply(200, { txHash: TX_HASH, log: "", retryDuration: 0, flag: 144 }); + + const args = baseRelayArgs(); + await relayMetaTransaction({ + ...args, + metaTx: { + ...args.metaTx, + params: { + ...args.metaTx.params, + transferAuthorizations: authorizations + } + } + }); + + expect(capturedBody?.params).toBeDefined(); + const params = (capturedBody as { params: unknown[] }).params; + expect(params.length).toBe(6); + expect(params[5]).toBe(encodeTransferAuthorizationQueue(authorizations)); + }); + + test("omits the auth queue when transferAuthorizations is an empty array", async () => { + let capturedBody: { params: unknown[] } | undefined; + nock(biconomyUrl) + .post("/api/v2/meta-tx/native", (body) => { + capturedBody = body as { params: unknown[] }; + return true; + }) + .reply(200, { txHash: TX_HASH, log: "", retryDuration: 0, flag: 144 }); + + const args = baseRelayArgs(); + await relayMetaTransaction({ + ...args, + metaTx: { + ...args.metaTx, + params: { ...args.metaTx.params, transferAuthorizations: [] } + } + }); + + expect((capturedBody as { params: unknown[] }).params.length).toBe(5); + }); + }); + // ── getResubmitted ───────────────────────────────────────────────────────── test("getResubmitted", async () => { const biconomyUrl = "https://api.biconomy.io"; diff --git a/packages/core-sdk/tests/meta-tx/mixin.test.ts b/packages/core-sdk/tests/meta-tx/mixin.test.ts index e0191cd8b..bc819d26c 100644 --- a/packages/core-sdk/tests/meta-tx/mixin.test.ts +++ b/packages/core-sdk/tests/meta-tx/mixin.test.ts @@ -33,6 +33,7 @@ const FORWARDER = "0x0000000000000000000000000000000000000003"; const SIGNER = "0x0000000000000000000000000000000000000004"; const VOUCHER_CLONE = "0x0000000000000000000000000000000000000005"; const ASSISTANT = "0x0000000000000000000000000000000000000006"; +const PERMIT2 = "0x0000000000000000000000000000000000000007"; // A real-looking ECDSA signature returned by MockWeb3LibAdapter.send() const MOCK_SIG = "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b"; @@ -65,7 +66,8 @@ function makeCoreSDK() { contracts: { protocolDiamond: PROTOCOL_DIAMOND, priceDiscoveryClient: PRICE_DISCOVERY, - forwarder: FORWARDER + forwarder: FORWARDER, + permit2: PERMIT2 } }); } diff --git a/packages/core-sdk/tests/native-meta-tx/mixin.test.ts b/packages/core-sdk/tests/native-meta-tx/mixin.test.ts index f8de4acaf..b3f61218d 100644 --- a/packages/core-sdk/tests/native-meta-tx/mixin.test.ts +++ b/packages/core-sdk/tests/native-meta-tx/mixin.test.ts @@ -15,6 +15,7 @@ const FORWARDER = "0x0000000000000000000000000000000000000003"; const SIGNER = "0x0000000000000000000000000000000000000004"; const EXCHANGE_TOKEN = "0x0000000000000000000000000000000000000010"; const CUSTOM_SPENDER = "0x0000000000000000000000000000000000000011"; +const PERMIT2 = "0x0000000000000000000000000000000000000007"; const VALUE = "1000000000000000000"; const MOCK_SIG = "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b"; @@ -46,7 +47,8 @@ function makeCoreSDK() { contracts: { protocolDiamond: PROTOCOL_DIAMOND, priceDiscoveryClient: PRICE_DISCOVERY, - forwarder: FORWARDER + forwarder: FORWARDER, + permit2: PERMIT2 } }); }