Skip to content

Commit

Permalink
add ens
Browse files Browse the repository at this point in the history
Co-Authored-By: Edouard Bougon <[email protected]>
  • Loading branch information
chakra-guy and EdouardBougon committed Sep 4, 2024
1 parent aca772e commit d4e8937
Show file tree
Hide file tree
Showing 4 changed files with 268 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/adapters/ens/expiration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ENS

Adapter to notify the user when an ENS name he owned is about to expire.
168 changes: 168 additions & 0 deletions src/adapters/ens/expiration/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as t from "bun:test";
import * as uuid from "uuid";
import * as adapters from "#/adapters";
import * as ens_expiration from "#/adapters/ens/expiration";
import * as domain from "#/domain";
import * as testutils from "#/testutils";

t.describe("ens_expiration adapter", () => {
const adapter = new ens_expiration.Adapter();
const publicClient = testutils.createRPCClient();
const defaultReminderDelayInSeconds = 60 * 60 * 24 * 7;
const nowInSeconds = Date.now() / 1000;

const trigger: domain.Trigger<ens_expiration.UserSettings, ens_expiration.State> = {
id: uuid.v4(),
chainId: domain.Chain.Ethereum,
kind: domain.Kind.EnsExpiration,
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
userSettings: { reverseEnsName: "vitalik.eth", reminderDelayInSeconds: defaultReminderDelayInSeconds },
state: null,
matchDedupKey: null,
scheduledAt: new Date(),
};

t.describe("check user", () => {
t.test("should handle not supported chain", async () => {
const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.None, publicClient);
t.expect(result).toEqual({ active: false, error: new adapters.NotSupportedChainError() });
});

t.test("should call with the right args", async () => {
const getEnsName = t.spyOn(publicClient, "getEnsName").mockResolvedValue(null);

await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, publicClient);

t.expect(getEnsName).toHaveBeenCalledWith({
address: "0x12Dec026d5826F95bA23957529B36a386E085583",
});

getEnsName.mockRestore();
});

t.test("should NOT handle user WITHOUT a reverse name", async () => {
const getEnsName = t.spyOn(publicClient, "getEnsName").mockResolvedValue(null);

const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, publicClient);

t.expect(result).toEqual({ active: false, error: new adapters.NotActiveUserError() });

getEnsName.mockRestore();
});

t.test("should NOT handle user WITHOUT a first level reverse name", async () => {
const getEnsName = t.spyOn(publicClient, "getEnsName").mockResolvedValue("test.linea.eth");

const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, publicClient);

t.expect(result).toEqual({ active: false, error: new adapters.NotActiveUserError() });

getEnsName.mockRestore();
});

t.test("should NOT handle user WITH a subdomain reverse name", async () => {
const getEnsName = t.spyOn(publicClient, "getEnsName").mockResolvedValue("slasha.vitalik.eth");

const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, publicClient);

t.expect(result).toEqual({ active: false, error: new adapters.NotActiveUserError() });

getEnsName.mockRestore();
});

t.test("should handle user WITH a first level reverse name", async () => {
const getEnsName = t.spyOn(publicClient, "getEnsName").mockResolvedValue("vitalik.eth");

const result = await adapter.checkUser("0x12Dec026d5826F95bA23957529B36a386E085583", domain.Chain.Ethereum, publicClient);

t.expect(result).toEqual({
active: true,
userSettings: { reverseEnsName: "vitalik.eth", reminderDelayInSeconds: adapter["DEFAULT_REMINDER_DELAY_IN_SECONDS"] },
});

getEnsName.mockRestore();
});
});

t.describe("matching", () => {
t.test("should error when chain is not supported", async () => {
const result = await adapter.matchTrigger({ ...trigger, chainId: domain.Chain.None }, publicClient);

t.expect(result).toEqual({ matched: false, error: new adapters.NotSupportedChainError() });
});

t.test("should error when ENS resolve an other address", async () => {
const getEnsAddress = t.spyOn(publicClient, "getEnsAddress").mockResolvedValue("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96046");

const result = await adapter.matchTrigger(trigger, publicClient);

t.expect(result).toEqual({ matched: false, error: new adapters.NotActiveUserError() });

getEnsAddress.mockRestore();
});

t.test("should error when ENS do not resolve a address anymore", async () => {
const getEnsAddress = t.spyOn(publicClient, "getEnsAddress").mockResolvedValue(null);

const result = await adapter.matchTrigger(trigger, publicClient);

t.expect(result).toEqual({ matched: false, error: new adapters.NotActiveUserError() });

getEnsAddress.mockRestore();
});

t.test("should match when expiration date is BEFORE the reminder delay", async () => {
// Mock the readContract method to return the expiration 1 day before the reminder delay
const expirationDateInSeconds = nowInSeconds + defaultReminderDelayInSeconds - 24 * 60 * 60;
const getEnsAddress = t.spyOn(publicClient, "getEnsAddress").mockResolvedValue(trigger.address);
const readContract = t.spyOn(publicClient, "readContract").mockResolvedValue(expirationDateInSeconds);

const expirationDateIso = new Date(expirationDateInSeconds * 1000).toISOString();

const result = await adapter.matchTrigger(trigger, publicClient);

t.expect(result).toEqual({
matched: true,
dedupKey: adapters.hash(`${trigger.userSettings.reverseEnsName}-${expirationDateIso}`),
context: { reverseEnsName: trigger.userSettings.reverseEnsName, expirationDateIso },
});

readContract.mockRestore();
getEnsAddress.mockRestore();
});

t.test("should NOT match when expiration date is AFTER the reminder delay", async () => {
// Mock the readContract method to return the expiration 1 day after the reminder delay
const expirationDateInSeconds = nowInSeconds + defaultReminderDelayInSeconds + 24 * 60 * 60;
const getEnsAddress = t.spyOn(publicClient, "getEnsAddress").mockResolvedValue(trigger.address);
const readContract = t.spyOn(publicClient, "readContract").mockResolvedValue(expirationDateInSeconds);

const result = await adapter.matchTrigger(trigger, publicClient);

t.expect(result).toEqual({
matched: false,
});

readContract.mockRestore();
getEnsAddress.mockRestore();
});
});

t.describe("mapping", () => {
t.test("should map into notification data", async () => {
const expirationDateInSeconds = nowInSeconds + defaultReminderDelayInSeconds - 24 * 60 * 60;
const context: ens_expiration.Context = {
reverseEnsName: trigger.userSettings.reverseEnsName,
expirationDateIso: new Date(expirationDateInSeconds * 1000).toISOString(),
};
const data = await adapter.mapIntoNotificationData(trigger, context);

t.expect(data).toEqual({
chainId: domain.Chain.Ethereum,
reverseEnsName: context.reverseEnsName,
expirationDateIso: context.expirationDateIso,
reminderDelayInSeconds: trigger.userSettings.reminderDelayInSeconds,
});
});
});
});
95 changes: 95 additions & 0 deletions src/adapters/ens/expiration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as viem from "viem";
import * as adapters from "#/adapters";
import * as domain from "#/domain";

export type UserSettings = {
reverseEnsName: string;
reminderDelayInSeconds: number;
};

export type State = null;

export type Context = {
reverseEnsName: string;
expirationDateIso: string;
};

export class Adapter implements adapters.IContractAdapter<UserSettings, State, Context> {
private readonly ENS_REGISTRAR_ADDRESS = "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85";

private readonly ENS_REGISTRAR_ABI = viem.parseAbi(["function nameExpires(uint256 id) view returns (uint256)"]);

private readonly DEFAULT_REMINDER_DELAY_IN_SECONDS = 7 * 24 * 60 * 60; // 1 week

// check if the user address reverse to a valid ENS domain
public async checkUser(address: viem.Address, chainId: domain.Chain, client: viem.PublicClient): Promise<adapters.UserCheckResult<UserSettings>> {
if (chainId !== domain.Chain.Ethereum) {
return { active: false, error: new adapters.NotSupportedChainError() };
}

const reverseEnsName = await client.getEnsName({ address });

// if the user doesn't have a reverse ENS name, it's not an active user
if (!reverseEnsName) {
return { active: false, error: new adapters.NotActiveUserError() };
}

// if the reverse ENS name is not a second level domain, it's not an active user
const labels = reverseEnsName.split(".");
if (labels.length !== 2 && labels[1] !== "eth") {
return { active: false, error: new adapters.NotActiveUserError() };
}

return { active: true, userSettings: { reverseEnsName, reminderDelayInSeconds: this.DEFAULT_REMINDER_DELAY_IN_SECONDS } };
}

public async matchTrigger(trigger: domain.Trigger<UserSettings, State>, client: viem.PublicClient): Promise<adapters.MatchResult<State, Context>> {
// check if the chain is supported
if (trigger.chainId !== domain.Chain.Ethereum) {
return { matched: false, error: new adapters.NotSupportedChainError() };
}

// check if the trigger address is still the resolved address of the ENS
const resolvedAddress = await client.getEnsAddress({ name: trigger.userSettings.reverseEnsName });
if (!resolvedAddress || viem.getAddress(resolvedAddress) !== viem.getAddress(trigger.address)) {
return { matched: false, error: new adapters.NotActiveUserError() };
}

// get ENS token id
const ensName = trigger.userSettings.reverseEnsName;
const label = ensName.split(".")[0];
const tokenId = BigInt(viem.keccak256(viem.toHex(label)));

// get expiration date
const expirationDate = await client.readContract({
address: this.ENS_REGISTRAR_ADDRESS,
abi: this.ENS_REGISTRAR_ABI,
functionName: "nameExpires",
args: [tokenId],
});

// get delay before expiration
const expirationDateInSeconds = Number(expirationDate);
const expirationDateInMs = expirationDateInSeconds * 1000;
const timeBeforeExpirationInSeconds = (expirationDateInMs - Date.now()) / 1000;

// check if the expiration date is close enough, if so, return a match
if (timeBeforeExpirationInSeconds < trigger.userSettings.reminderDelayInSeconds) {
const expirationDateIso = new Date(expirationDateInMs).toISOString();
const dedupKey = adapters.hash(`${trigger.userSettings.reverseEnsName}-${expirationDateIso}`);

return { matched: true, dedupKey, context: { reverseEnsName: trigger.userSettings.reverseEnsName, expirationDateIso } };
}

return { matched: false };
}

public async mapIntoNotificationData(trigger: domain.Trigger<UserSettings, State>, context: Context): Promise<domain.NotificationData> {
return {
chainId: trigger.chainId,
reverseEnsName: context.reverseEnsName,
expirationDateIso: context.expirationDateIso,
reminderDelayInSeconds: trigger.userSettings.reminderDelayInSeconds,
};
}
}
2 changes: 2 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as viem from "viem";
import * as aave_v3_health_factor from "#/adapters/aave/aave_v3_health_factor";
import * as ens_expiration from "#/adapters/ens/expiration";
import * as test_is_active_user from "#/adapters/test/is_active_user";
import * as test_is_matching from "#/adapters/test/is_matching";
import * as test_is_not_active_user from "#/adapters/test/is_not_active_user";
Expand All @@ -10,6 +11,7 @@ import * as domain from "#/domain";
export const CONTRACT_ADAPTERS: ContractAdapters = {
// Actual adapters
[domain.Kind.AaveV3HealthFactor]: new aave_v3_health_factor.Adapter(),
[domain.Kind.EnsExpiration]: new ens_expiration.Adapter(),

// Test adapters
[domain.Kind.TestIsMatching]: new test_is_matching.Adapter(),
Expand Down

0 comments on commit d4e8937

Please sign in to comment.