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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 135 additions & 84 deletions contracts/compiled/EnsGetter.json

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions contracts/deployless/EnsGetter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ interface IUniversalResolver {
) external view returns (string memory resolvedName, address resolver, address reverseResolver);
}

interface IBaseRegistrar {
function nameExpires(uint256 id) external view returns (uint256);

function GRACE_PERIOD() external view returns (uint256);
}

interface INameWrapper {
function getData(uint256 id) external view returns (address owner, uint32 fuses, uint64 expiry);
}

contract EnsGetter {
struct ReverseLookupResult {
string resolvedName;
Expand All @@ -19,6 +29,15 @@ contract EnsGetter {
bool needsOffchainLookup;
}

struct ExpiryResult {
// Registration expiry, in seconds
uint256 expiry;
// Grace period, in seconds. 0 on the NameWrapper path (the wrapper expiry has no separate grace)
uint256 gracePeriod;
// block.timestamp of the eth_call, so the caller's updatedAt is consistent with the expiry snapshot
uint256 blockTimestamp;
}

// EIP-3668
bytes4 constant OFFCHAIN_LOOKUP_SELECTOR = 0x556f1830;

Expand Down Expand Up @@ -67,4 +86,25 @@ contract EnsGetter {
gateways
);
}

// Batches ENS expiry calls. Routing is decided by the caller:
// - useRegistrar == true: `.eth` 2LD, read from the BaseRegistrar (expiry + separate GRACE_PERIOD).
// - useRegistrar == false: subnames / non-`.eth` names, read from the NameWrapper (no grace period).
// `id` is the registrar token id (labelhash of the first label) or the wrapper node (namehash),
function getExpiry(
bool useRegistrar,
address baseRegistrar,
address nameWrapper,
uint256 id
) external view returns (ExpiryResult memory result) {
result.blockTimestamp = block.timestamp;

if (useRegistrar) {
result.expiry = IBaseRegistrar(baseRegistrar).nameExpires(id);
result.gracePeriod = IBaseRegistrar(baseRegistrar).GRACE_PERIOD();
} else {
(, , uint64 wrapperExpiry) = INameWrapper(nameWrapper).getData(id);
result.expiry = wrapperExpiry;
}
}
}
78 changes: 69 additions & 9 deletions src/controllers/activity/activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAddress } from 'ethers'
import fetch from 'node-fetch'

import { describe, expect } from '@jest/globals'
Expand Down Expand Up @@ -186,14 +187,14 @@ describe('Activity Controller ', () => {
const { controller } = await prepareTest()

const trustedRecipient = '0xF0cD725D2195b1D3f4BD038c3786005B793237DB'
const poisoningRecipient4 = '0xF0cDaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa37DB'
const poisoningRecipient5 = '0xF0cD7bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb237DB'
const poisoningRecipient6 = '0xF0cD72ccccccccccccccccccccccccccccc237DB'
const poisoningRecipient4to8 = '0xF0cDdddddddddddddddddddddddddddd793237DB'
const poisoningRecipient3to8 = '0xF0ceeeeeeeeeeeeeeeeeeeeeeeeeeeee793237DB'
const poisoningRecipient0to8 = '0xAb12ffffffffffffffffffffffffffff793237DB'
const poisoningRecipient3to4 = '0xF0caaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa37DB'
const poisoningRecipient0to0 = '0xAb12eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeCDef'
const poisoningRecipient4 = '0xF0cdAaAaaAAAaAAAaaaAaAaAAaAaaaAaAaaA37Db'
const poisoningRecipient5 = '0xf0cd7BbbBbbbBbBBbbbBBBBbbbbBBbbbbbb237DB'
const poisoningRecipient6 = '0xf0cd72ccCCccCcCccCCCCcCCcCcCCCCccCC237DB'
const poisoningRecipient4to8 = '0xF0CdDDDddddDdDdDdDDDDDDDDdddDDDD793237db'
const poisoningRecipient3to8 = '0xF0cEEeeeEEEEEeEEEEeeEEEEeeEeeeEe793237db'
const poisoningRecipient0to8 = '0xaB12ffFFfFFFFfFfFFFffffFffFFFfFF793237DB'
const poisoningRecipient3to4 = '0xF0cAAaAaAaaaAAaAAaaaaAAaaaAaAAAaAAaa37dB'
const poisoningRecipient0to0 = '0xAB12eeeeeeeEeeeEeeeEEeeeEEEEEeeEeEeeCdef'

await controller.addAccountOp({
...SUBMITTED_ACCOUNT_OP,
Expand Down Expand Up @@ -323,7 +324,7 @@ describe('Activity Controller ', () => {
test('should not detect poisoning without transaction history', async () => {
const { controller } = await prepareTest()

const poisoningRecipient4to4 = '0xF0cDaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa37DB'
const poisoningRecipient4to4 = '0xF0cdAaAaaAAAaAAAaaaAaAaAAaAaaaAaAaaA37Db'
const normalizedPoisoningRecipient4to4 = poisoningRecipient4to4.toLowerCase()

const firstTimeSendResult = await controller.hasAccountOpsSentTo(
Expand Down Expand Up @@ -1114,4 +1115,63 @@ describe('Activity Controller ', () => {
expect(controller.accountsOps[sessionId]!.result!.items.length).toEqual(0)
expect(controller.signedMessages[sessionId]!.result!.items.length).toEqual(0)
})

describe('Sent-to history', () => {
const DOMAIN_ADDR_A = '0xF0cD725D2195b1D3f4BD038c3786005B793237DB'
const DOMAIN_ADDR_B = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'

const SENT_AT = new Date('2024-03-01T10:00:00Z').getTime()
const SENT_AT_LATER = new Date('2024-06-15T18:30:00Z').getTime()

it('records and reads the address a domain was sent to (global, case-insensitive)', async () => {
const { controller } = await prepareTest()

// await controller.recordSentToDomain('alice.eth', DOMAIN_ADDR_A, SENT_AT)
await controller.addAccountOp({
...SUBMITTED_ACCOUNT_OP,
calls: [{ to: DOMAIN_ADDR_A, recipientDomain: 'alice.eth', value: 0n, data: '0x' }]
})

// Checksummed, and the domain lookup is case-insensitive.
expect(controller.getSentToDomainAddress('alice.eth')).toBe(getAddress(DOMAIN_ADDR_A))
expect(controller.getSentToDomainAddress('ALICE.eth')).toBe(getAddress(DOMAIN_ADDR_A))
})

it('overwrites with the most recent address', async () => {
const { controller } = await prepareTest()

await controller.addAccountOp({
...SUBMITTED_ACCOUNT_OP,
timestamp: SENT_AT,
calls: [{ to: DOMAIN_ADDR_B, recipientDomain: 'alice.eth', value: 0n, data: '0x' }]
})
await controller.addAccountOp({
...SUBMITTED_ACCOUNT_OP,
timestamp: SENT_AT_LATER,
calls: [{ to: DOMAIN_ADDR_A, recipientDomain: 'alice.eth', value: 0n, data: '0x' }]
})

expect(controller.getSentToDomainAddress('alice.eth')).toBe(getAddress(DOMAIN_ADDR_A))
})

it('stores recipients checksummed', async () => {
const { controller } = await prepareTest()

const recipientLower = '0xf0cd725d2195b1d3f4bd038c3786005b793237db'
await controller.addAccountOp({
...SUBMITTED_ACCOUNT_OP,
nonce: 302n,
txnId: '0x4c8a1d6f93b072e5af18c34d9e6072b1f5a83c0d7e29b46f1a0c5d8e3b97f246',
timestamp: SENT_AT_LATER,
calls: [{ to: recipientLower, value: 0n, data: '0x' }]
})

const stored = await storage.get('sentToHistory', { domains: {}, recipients: {} })
const recipientsForAccount = stored.recipients[SUBMITTED_ACCOUNT_OP.accountAddr]
expect(recipientsForAccount).toBeDefined()

expect(recipientsForAccount![getAddress(recipientLower)]).toBe(SENT_AT_LATER)
expect(recipientsForAccount![recipientLower]).toBeUndefined()
})
})
})
76 changes: 66 additions & 10 deletions src/controllers/activity/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ import { filterStaticBlacklistedAddrs } from '../../libs/portfolio/blacklist'
import { ScamFilter } from '../../libs/scamFilter'
import { parseLogs } from '../../libs/userOperation/userOperation'
import { getDebugTraceTransaction } from '../../utils/debugTransaction'
import { getAddressCaught } from '../../utils/getAddressCaught'
import wait from '../../utils/wait'
import EventEmitter from '../eventEmitter/eventEmitter'
import { InternalSignedMessages, SignedMessage } from './types'
import { InternalSignedMessages, SentToHistory, SignedMessage } from './types'

import type { BalanceChangesReceipt } from '../../libs/accountOp/balanceChanges'

// TODO: Move all of these types and helpers to separate files!
export interface Pagination {
fromPage: number
itemsPerPage: number
Expand All @@ -60,7 +63,7 @@ interface PaginationResult<T> {
maxPages: number
}

interface AccountsOps extends PaginationResult<SubmittedAccountOpLike> {}
type AccountsOps = PaginationResult<SubmittedAccountOpLike>

type AddExternalAccountOpParams = {
accountAddr: string
Expand All @@ -76,7 +79,7 @@ type AccountOpBalanceChangesBackfillReference = Pick<
'identifiedBy' | 'accountAddr' | 'chainId'
>

interface MessagesToBeSigned extends PaginationResult<SignedMessage> {}
type MessagesToBeSigned = PaginationResult<SignedMessage>

export interface Filters {
account: string
Expand Down Expand Up @@ -254,6 +257,8 @@ export class ActivityController extends EventEmitter implements IActivityControl

#externalAccountOps: ExternalAccountOps = {}

#sentToHistory: SentToHistory = { domains: {}, recipients: {} }

accountsOps: {
[sessionId: string]: {
result: AccountsOps
Expand Down Expand Up @@ -338,15 +343,17 @@ export class ActivityController extends EventEmitter implements IActivityControl
async #load(): Promise<void> {
await this.#accounts.initialLoadPromise
await this.#selectedAccount.initialLoadPromise
const [accountsOps, externalAccountOps, signedMessages] = await Promise.all([
const [accountsOps, externalAccountOps, signedMessages, sentToHistory] = await Promise.all([
this.#storage.get('accountsOps', {}),
this.#storage.get('externalAccountOps', {}),
this.#storage.get('signedMessages', {})
this.#storage.get('signedMessages', {}),
this.#storage.get('sentToHistory', { domains: {}, recipients: {} })
])

this.#accountsOps = accountsOps
this.#externalAccountOps = externalAccountOps
this.#signedMessages = signedMessages
this.#sentToHistory = sentToHistory

this.emitUpdate()
}
Expand All @@ -366,6 +373,18 @@ export class ActivityController extends EventEmitter implements IActivityControl
await this.#initialLoadPromise
if (!toAddress) return { found: false, lastTransactionDate: null, addressPoisoningMatch: null }

const checksummedToAddress = getAddressCaught(toAddress)
const lastSentAt =
checksummedToAddress && this.#sentToHistory.recipients[accountId]?.[checksummedToAddress]

// No need to check all account ops if we have this data
if (lastSentAt)
return {
found: true,
lastTransactionDate: new Date(lastSentAt),
addressPoisoningMatch: null
}

const accounts = accountId ? [accountId] : Object.keys(this.#accountsOps)
let found = false
let lastTimestamp: number | null = null
Expand Down Expand Up @@ -395,7 +414,7 @@ export class ActivityController extends EventEmitter implements IActivityControl
const networkAccountOpsOfAccount = accountOpsOfAccount[network]
if (!networkAccountOpsOfAccount) return
networkAccountOpsOfAccount.forEach((op) => {
const recipients = getAccountOpRecipients(op)
const recipients = getAccountOpRecipients(op).map((recipient) => recipient.address)
const hasSentToRecipient = recipients.some((recipient) => {
if (recipient.toLowerCase() === normalizedToAddress) return true

Expand Down Expand Up @@ -507,7 +526,7 @@ export class ActivityController extends EventEmitter implements IActivityControl
op.balanceChanges === undefined
)
if (opsWithNoBalanceChanges.length)
this.backfillAccountOpBalanceChangesAndPersist(opsWithNoBalanceChanges).catch((e) => null)
this.backfillAccountOpBalanceChangesAndPersist(opsWithNoBalanceChanges).catch(() => null)
}

setDashboardBannersSeen(sessionId: string, accountAddr: string) {
Expand Down Expand Up @@ -680,12 +699,49 @@ export class ActivityController extends EventEmitter implements IActivityControl
this.#accountsOps[accountAddr]![chainId.toString()]!.unshift({ ...accountOp })
trim(this.#accountsOps[accountAddr][chainId.toString()]!)

getAccountOpRecipients(accountOp).forEach((recipient) =>
this.#recordRecipient(accountAddr, recipient.address, recipient.domain, accountOp.timestamp)
)

await this.syncFilteredAccountsOps()

await this.#storage.set('accountsOps', this.#accountsOps)
await this.#storage.set('sentToHistory', this.#sentToHistory)
this.emitUpdate()
}

#recordRecipient(
accountId: AccountId,
toAddress: string,
toDomain: string | undefined,
timestamp: number
) {
const checksummedAddress = getAddressCaught(toAddress)
if (!checksummedAddress) return

if (!this.#sentToHistory.recipients[accountId]) this.#sentToHistory.recipients[accountId] = {}

const existing = this.#sentToHistory.recipients[accountId]![checksummedAddress] || 0
this.#sentToHistory.recipients[accountId]![checksummedAddress] = Math.max(existing, timestamp)

const normalized = toDomain?.toLowerCase().trim()

if (normalized) {
this.#sentToHistory.domains[normalized] = {
address: checksummedAddress,
sentAt: timestamp
}
}
}

/** Returns the address a domain last resolved to when the user sent to it, or null if never. */
getSentToDomainAddress(domain: string): string | null {
const normalizedDomain = domain.toLowerCase().trim()
if (!normalizedDomain) return null

return this.#sentToHistory.domains[normalizedDomain]?.address ?? null
}

async addExternalAccountOp({
accountAddr,
chainId,
Expand Down Expand Up @@ -817,7 +873,7 @@ export class ActivityController extends EventEmitter implements IActivityControl
this.#providers.providers[network.chainId.toString()]
)
})
} catch (error) {
} catch {
submittedAccountOpLike.balanceChanges = undefined
}

Expand Down Expand Up @@ -1292,7 +1348,7 @@ export class ActivityController extends EventEmitter implements IActivityControl
const accountOpRecipients = getAccountOpRecipients(
accountOp,
this.#accounts.accounts.map((a) => a.addr)
)
).map((recipient) => recipient.address)

accountOpRecipients.forEach((accAddr) => {
if (!portfoliosToUpdate[accAddr]) portfoliosToUpdate[accAddr] = []
Expand Down Expand Up @@ -1357,7 +1413,7 @@ export class ActivityController extends EventEmitter implements IActivityControl

// record the balance changes but do not await them
// no need to console.log errors in the catch() as it's handled inside
this.#executeBalanceChanges(balanceChangesTasks).catch((e) => null)
this.#executeBalanceChanges(balanceChangesTasks).catch(() => null)

return {
shouldEmitUpdate,
Expand Down
11 changes: 11 additions & 0 deletions src/controllers/activity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ export interface SignedMessage extends Message {
export interface InternalSignedMessages {
[key: AccountId]: SignedMessage[]
}

/**
* Persistent index of where the user has sent funds. Serves two purposes:
* 1. per-account "have I sent to this address before" (fast path for `hasAccountOpsSentTo`), and
* 2. "what address did this domain resolve to the last time I sent to it" (ENS/Namoshi
* changed-address protection). (from any account)
*/
export interface SentToHistory {
domains: { [domain: string]: { address: string; sentAt: number } }
recipients: { [accountId: string]: { [toAddress: string]: number } }
}
Loading