Skip to content

Commit

Permalink
(bundler) check for transfers to sanctioned addresses (#576)
Browse files Browse the repository at this point in the history
* add erc20 parsing logic + scaffold sanction check

* add sanctions list contract, wire up rest of checks

* remove todo

* changeset

* fix typo

* fix action parsing

* switch rpc urls

* rename

---------

Co-authored-by: Luke Tchang <[email protected]>
  • Loading branch information
Sladuca and luketchang authored Nov 10, 2023
1 parent e69bcb2 commit 57023ec
Show file tree
Hide file tree
Showing 12 changed files with 575 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/smart-paws-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nocturne-xyz/bundler": patch
---

check outgoing transfers against sanctions list
188 changes: 188 additions & 0 deletions actors/bundler/src/abis/SanctionsList.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "addr",
"type": "address"
}
],
"name": "NonSanctionedAddress",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "addr",
"type": "address"
}
],
"name": "SanctionedAddress",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address[]",
"name": "addrs",
"type": "address[]"
}
],
"name": "SanctionedAddressesAdded",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address[]",
"name": "addrs",
"type": "address[]"
}
],
"name": "SanctionedAddressesRemoved",
"type": "event"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "newSanctions",
"type": "address[]"
}
],
"name": "addToSanctionsList",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "addr",
"type": "address"
}
],
"name": "isSanctioned",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "addr",
"type": "address"
}
],
"name": "isSanctionedVerbose",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "removeSanctions",
"type": "address[]"
}
],
"name": "removeFromSanctionsList",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
38 changes: 38 additions & 0 deletions actors/bundler/src/actionParsing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Action } from "@nocturne-xyz/core";
import * as ethers from "ethers";

export function getSelector(signature: string): string {
const sigBytes = ethers.utils.toUtf8Bytes(signature);
const hash = ethers.utils.keccak256(sigBytes);
return ethers.utils.hexDataSlice(hash, 0, 4);
}

// same for both ETHTransferAdapter and ERC20 Transfer
const TRANSFER_SELECTOR = getSelector("transfer(address,uint256)");

export type TransferActionCalldata = {
to: string;
amount: string;
};

export function isTransferAction(action: Action): boolean {
const selector = ethers.utils.hexDataSlice(action.encodedFunction, 0, 4);
return selector === TRANSFER_SELECTOR;
}

export function parseTransferAction(action: Action): TransferActionCalldata {
if (!isTransferAction(action)) {
throw new Error("Not an ERC20 transfer action");
}

const calldata = ethers.utils.hexDataSlice(action.encodedFunction, 4);
const [to, amount] = ethers.utils.defaultAbiCoder.decode(
["address", "uint256"],
calldata
) as [string, ethers.BigNumber];

return {
to,
amount: amount.toString(),
};
}
1 change: 1 addition & 0 deletions actors/bundler/src/cli/commands/run/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const runServer = new Command("server")
logLevel,
logDir
);

const server = new BundlerServer(
bundlerAddress,
config.tellerAddress,
Expand Down
37 changes: 37 additions & 0 deletions actors/bundler/src/opValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Handler, Teller } from "@nocturne-xyz/contracts";
import { NullifierDB } from "./db";
import { Logger } from "winston";
import { ErrString } from "@nocturne-xyz/offchain-utils";
import { isTransferAction, parseTransferAction } from "./actionParsing";
import { isSanctionedAddress } from "./sanctions";

export async function checkNullifierConflictError(
db: NullifierDB,
Expand Down Expand Up @@ -102,3 +104,38 @@ export async function checkNotEnoughGasError(
return `operation ${id} gas price too low: ${operation.gasPrice} < current chain's gas price ${gasPrice}`;
}
}

export async function checkIsNotTransferToSanctionedAddress(
provider: ethers.providers.Provider,
logger: Logger,
operation: SubmittableOperationWithNetworkInfo
): Promise<ErrString | undefined> {
logger.debug(
"checking that operation doesn't contain any transfers to a sanctioned address"
);

const transferActions = operation.actions.filter(isTransferAction);
const opDigest = OperationTrait.computeDigest(operation).toString();
const results = await Promise.all(
transferActions.map(async (action, i) => {
const { to, amount } = parseTransferAction(action);
if (await isSanctionedAddress(provider, to)) {
logger.alert("detected transfer to sanctioned address", {
opDigest,
actionIndex: i,
recipient: to,
amount,
contract: action.contractAddress,
});
return true;
}

return false;
})
);

const sanctionedTransfers = results.filter((result) => result === true);
if (sanctionedTransfers.length > 0) {
return `operation ${opDigest} contains ${sanctionedTransfers.length} transfer(s) to sanctioned addresses`;
}
}
13 changes: 13 additions & 0 deletions actors/bundler/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@nocturne-xyz/core";
import { Handler, Teller } from "@nocturne-xyz/contracts";
import {
checkIsNotTransferToSanctionedAddress,
checkNotEnoughGasError,
checkNullifierConflictError,
checkRevertError,
Expand Down Expand Up @@ -124,6 +125,18 @@ export function makeRelayHandler({
return;
}

const sanctionedTransferErr = await checkIsNotTransferToSanctionedAddress(
provider,
logger,
operation
);
if (sanctionedTransferErr) {
logValidationFailure(sanctionedTransferErr);
// TODO: add histogram for sanctioned transfers?
res.status(400).json(sanctionedTransferErr);
return;
}

// Enqueue operation and add all inflight nullifiers
let jobId;
try {
Expand Down
25 changes: 25 additions & 0 deletions actors/bundler/src/sanctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Address } from "@nocturne-xyz/core";
import * as ethers from "ethers";
import SanctionsListAbi from "./abis/SanctionsList.json";

const CHAIN_ID_TO_SANCTIONS_LIST_CONTRACT: Record<number, Address> = {
1: "0x40c57923924b5c5c5455c48d93317139addac8fb",
};

export async function isSanctionedAddress(
provider: ethers.providers.Provider,
address: Address
): Promise<boolean> {
const chainId = (await provider.getNetwork()).chainId;
// skip check if there's no sanctions contract on this chain
if (!CHAIN_ID_TO_SANCTIONS_LIST_CONTRACT[chainId]) {
return false;
}

const contract = new ethers.Contract(
CHAIN_ID_TO_SANCTIONS_LIST_CONTRACT[chainId],
SanctionsListAbi,
provider
);
return (await contract.isSanctioned(address)) as unknown as boolean;
}
Loading

0 comments on commit 57023ec

Please sign in to comment.