diff --git a/.env.example b/.env.example index 369ae11..aba649d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ CHAIN_ID=100 IPFS_URL=https://ipfs.internal.citizenwallet.xyz +DEFAULT_PROFILE_IMAGE_IPFS_HASH=ipfs://Qm... # Supabase SUPABASE_URL=https://... diff --git a/deno.json b/deno.json index 108710c..0ad72f6 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,7 @@ "imports": { "@/firebase": "./supabase/functions/_firebase", "@/supabase": "./supabase", - "@citizenwallet/sdk": "jsr:@citizenwallet/sdk@^2.0.6", + "@citizenwallet/sdk": "jsr:@citizenwallet/sdk@^2.0.7", "@supabase/functions-js": "jsr:@supabase/functions-js@^2.4.3", "@supabase/supabase-js": "jsr:@supabase/supabase-js@^2.45.6", "ethers": "npm:ethers@^6.13.4", diff --git a/deno.lock b/deno.lock index 4d48f73..a0ffe7e 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,7 @@ { "version": "4", "specifiers": { - "jsr:@citizenwallet/sdk@^2.0.6": "2.0.6", + "jsr:@citizenwallet/sdk@^2.0.7": "2.0.7", "jsr:@supabase/functions-js@^2.4.3": "2.4.3", "jsr:@supabase/supabase-js@^2.45.6": "2.45.6", "npm:@supabase/auth-js@2.65.1": "2.65.1", @@ -15,8 +15,8 @@ "npm:openai@^4.52.5": "4.68.4" }, "jsr": { - "@citizenwallet/sdk@2.0.6": { - "integrity": "36086f6605ac1b28b33f82d3d0bd40ade8c922f21f6a62a61af360b49f6cb9ab" + "@citizenwallet/sdk@2.0.7": { + "integrity": "2a150307a01ceb4667d615c2a96f1cf4420788b546bc9d818c96d8bd27753eae" }, "@supabase/functions-js@2.4.3": { "integrity": "010834c9e7d2996b4be8e4004a15ed8f42b326490e4b354683f9c191353b76cc", @@ -1016,7 +1016,7 @@ }, "workspace": { "dependencies": [ - "jsr:@citizenwallet/sdk@^2.0.6", + "jsr:@citizenwallet/sdk@^2.0.7", "jsr:@supabase/functions-js@^2.4.3", "jsr:@supabase/supabase-js@^2.45.6", "npm:ethers@^6.13.4", diff --git a/supabase/functions/_citizen-wallet/contracts/profiles/index.ts b/supabase/functions/_citizen-wallet/contracts/profiles/index.ts index 7976c96..690fbb8 100644 --- a/supabase/functions/_citizen-wallet/contracts/profiles/index.ts +++ b/supabase/functions/_citizen-wallet/contracts/profiles/index.ts @@ -8,11 +8,12 @@ import profileAbi from "./Profile.abi.json" with { type: "json", }; import { downloadJsonFromIpfs } from "../../ipfs/index.ts"; +import type { ProfileWithTokenId } from "../../../_db/profiles.ts"; export const getProfileFromId = async ( config: CommunityConfig, id: string, -): Promise => { +): Promise => { const rpc = new JsonRpcProvider(config.primaryRPCUrl); const contract = new Contract( @@ -35,7 +36,34 @@ export const getProfileFromId = async ( throw new Error("IPFS_URL is not set"); } - return formatProfileImageLinks(baseUrl, profile); + return { + ...formatProfileImageLinks(baseUrl, profile), + token_id: id, + }; + } catch (error) { + console.error("Error fetching profile:", error); + return; + } +}; + +export const getProfileFromAddress = async ( + config: CommunityConfig, + address: string, +): Promise => { + const rpc = new JsonRpcProvider(config.primaryRPCUrl); + + const contract = new Contract( + config.community.profile.address, + profileAbi, + rpc, + ); + + try { + const id: bigint = await contract.getFunction("fromAddressToId")( + address, + ); + + return getProfileFromId(config, id.toString()); } catch (error) { console.error("Error fetching profile:", error); return; diff --git a/supabase/functions/_citizen-wallet/index.ts b/supabase/functions/_citizen-wallet/index.ts index c7aa3ff..eba8373 100644 --- a/supabase/functions/_citizen-wallet/index.ts +++ b/supabase/functions/_citizen-wallet/index.ts @@ -16,6 +16,10 @@ export interface ERC20TransferData { value: string; } +export interface ERC20TransferExtraData { + description: string; +} + export interface MetadataUpdateData { _tokenId: string; } @@ -24,6 +28,14 @@ export const communityConfig = () => { return new CommunityConfig(communityJson); }; +export const formatERC20TransactionValue = ( + config: CommunityConfig, + value: string, +) => { + const token = config.primaryToken; + return formatUnits(value, token.decimals); +}; + export const createERC20TransferNotification = ( config: CommunityConfig, data: ERC20TransferData, diff --git a/supabase/functions/_citizen-wallet/profiles.ts b/supabase/functions/_citizen-wallet/profiles.ts new file mode 100644 index 0000000..cfcf5fe --- /dev/null +++ b/supabase/functions/_citizen-wallet/profiles.ts @@ -0,0 +1,37 @@ +import type { SupabaseClient } from "jsr:@supabase/supabase-js@2"; +import type { CommunityConfig } from "jsr:@citizenwallet/sdk"; +import { + getProfile, + insertAnonymousProfile, + upsertProfile, +} from "../_db/profiles.ts"; +import { getProfileFromAddress } from "./contracts/profiles/index.ts"; + +export const ensureProfileExists = async ( + client: SupabaseClient, + config: CommunityConfig, + address: string, +) => { + const { data, error } = await getProfile( + client, + address, + ); + + if (error || !data) { + // Check the smart contract for a profile + const profile = await getProfileFromAddress( + config, + address, + ); + + if (profile) { + await upsertProfile(client, profile); + } else { + // There is none, let's create an anonymous profile in the database + await insertAnonymousProfile( + client, + address, + ); + } + } +}; diff --git a/supabase/functions/_db/profiles.ts b/supabase/functions/_db/profiles.ts index f71c7f9..08d4d72 100644 --- a/supabase/functions/_db/profiles.ts +++ b/supabase/functions/_db/profiles.ts @@ -1,19 +1,50 @@ -import type { Profile } from "jsr:@citizenwallet/sdk"; +import { formatProfileImageLinks, type Profile } from "jsr:@citizenwallet/sdk"; import type { PostgrestSingleResponse, SupabaseClient, } from "jsr:@supabase/supabase-js@2"; +export interface ProfileWithTokenId extends Profile { + token_id: string; +} + const PROFILES_TABLE = "a_profiles"; +export const insertAnonymousProfile = async ( + client: SupabaseClient, + account: string, +): Promise> => { + const defaultProfileImageIpfsHash = Deno.env.get( + "DEFAULT_PROFILE_IMAGE_IPFS_HASH", + ); + if (!defaultProfileImageIpfsHash) { + throw new Error("DEFAULT_PROFILE_IMAGE_IPFS_HASH is not set"); + } + + const ipfsUrl = Deno.env.get("IPFS_URL"); + if (!ipfsUrl) { + throw new Error("IPFS_URL is not set"); + } + + const profile: Profile = formatProfileImageLinks(ipfsUrl, { + account, + username: "@anonymous", + name: "Anonymous", + description: "This user does not have a profile", + image: defaultProfileImageIpfsHash, + image_medium: defaultProfileImageIpfsHash, + image_small: defaultProfileImageIpfsHash, + }); + return client.from(PROFILES_TABLE).insert(profile); +}; + export const upsertProfile = async ( client: SupabaseClient, - profile: Profile, - tokenId: string, + profile: ProfileWithTokenId, ): Promise> => { return client .from(PROFILES_TABLE) - .upsert({ ...profile, token_id: tokenId }, { + .upsert(profile, { onConflict: "account", }); }; diff --git a/supabase/functions/_db/transactions.ts b/supabase/functions/_db/transactions.ts new file mode 100644 index 0000000..4c2df5f --- /dev/null +++ b/supabase/functions/_db/transactions.ts @@ -0,0 +1,25 @@ +import type { SupabaseClient } from "jsr:@supabase/supabase-js@2"; +import type { TransactionStatus } from "jsr:@citizenwallet/sdk"; + +export interface Transaction { + id: string; + hash: string; + created_at: string; + updated_at: string; + from: string; + to: string; + value: string; + description: string; + status: TransactionStatus; +} + +const TRANSACTIONS_TABLE = "a_transactions"; + +export const upsertTransaction = async ( + client: SupabaseClient, + transaction: Transaction, +) => { + return client.from(TRANSACTIONS_TABLE).upsert(transaction, { + onConflict: "id", + }); +}; diff --git a/supabase/functions/download-profile-data/index.ts b/supabase/functions/download-profile-data/index.ts index 7eadcc6..22749bf 100644 --- a/supabase/functions/download-profile-data/index.ts +++ b/supabase/functions/download-profile-data/index.ts @@ -67,7 +67,6 @@ Deno.serve(async (req) => { const result = await upsertProfile( supabaseClient, profile, - metadataUpdateData._tokenId, ); console.log(result); diff --git a/supabase/functions/process-tx/index.ts b/supabase/functions/process-tx/index.ts new file mode 100644 index 0000000..e3a3d00 --- /dev/null +++ b/supabase/functions/process-tx/index.ts @@ -0,0 +1,102 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; + +import { + communityConfig, + type ERC20TransferData, + type ERC20TransferExtraData, + formatERC20TransactionValue, +} from "../_citizen-wallet/index.ts"; +import { getServiceRoleClient } from "../_db/index.ts"; +import { type Transaction, upsertTransaction } from "../_db/transactions.ts"; +import { ensureProfileExists } from "../_citizen-wallet/profiles.ts"; + +Deno.serve(async (req) => { + const { record } = await req.json(); + + console.log("record", record); + + if (!record || typeof record !== "object") { + return new Response("Invalid record data", { status: 400 }); + } + + const { + hash, + tx_hash, + created_at, + updated_at, + dest, + status, + data, + extra_data, + } = record; + + if (!dest || typeof dest !== "string") { + return new Response( + "Destination address is required and must be a string", + { status: 400 }, + ); + } + + const community = communityConfig(); + + if (dest.toLowerCase() !== community.primaryToken.address.toLowerCase()) { + return new Response("Only process primary token transfers", { + status: 200, + }); + } + + // Initialize Supabase client + const supabaseClient = getServiceRoleClient(); + + const chainId = Deno.env.get("CHAIN_ID"); + if (!chainId) { + return new Response("CHAIN_ID is required", { status: 500 }); + } + + const erc20TransferData = data as ERC20TransferData; + + await ensureProfileExists(supabaseClient, community, erc20TransferData.from); + await ensureProfileExists(supabaseClient, community, erc20TransferData.to); + + let erc20TransferExtraData: ERC20TransferExtraData = { description: "" }; + if (extra_data) { + erc20TransferExtraData = extra_data as ERC20TransferExtraData; + } + + // insert transaction into db + const transaction: Transaction = { + id: hash, + hash: tx_hash, + created_at, + updated_at, + from: erc20TransferData.from, + to: erc20TransferData.to, + value: formatERC20TransactionValue(community, erc20TransferData.value), + description: erc20TransferExtraData.description || "", + status: status, + }; + + const { error } = await upsertTransaction(supabaseClient, transaction); + if (error) { + console.error("Error inserting transaction:", error); + } + + return new Response("notification sent", { status: 200 }); +}); + +/* To invoke locally: + + 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/notify-successful-transaction' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{"name":"Functions"}' + +*/