-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-Authored-By: Edouard Bougon <[email protected]>
- Loading branch information
1 parent
aca772e
commit d4e8937
Showing
4 changed files
with
268 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters