Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: coingecko API proxy/cache #44

Merged
merged 5 commits into from
Jan 15, 2024
Merged
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
3 changes: 3 additions & 0 deletions dev-environment/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ export default defineConfig({
'react-router-dom': require.resolve('react-router-dom'),
},
},
define: {
VITE_STAKING_SUPABASE_URL: process.env.VITE_STAKING_SUPABASE_URL,
},
});
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,44 @@ export const StakingZApp = ({ provider, web3 }: AppProps) => {
</QueryClientProvider>
);
};

/*
* 15/01/2024
* The following is gross. It's a hack to re-route any CG
* requests to our own proxy server (`supabase/functions/price`).
*
* This is necessary because it's easy to hit CG's rate limit.
* The proxy server will cache each response for 10 minutes, meaning
* we will only hit CG, at most, once every 10 minutes.
*
* The better alternative would be to handle this in the SDK, but
* we're moving fast and need a quick fix.
*/

const SUPABASE_URL =
import.meta.env.VITE_STAKING_SUPABASE_URL ??
process.env.REACT_APP_STAKING_SUPABASE_URL;

if (!SUPABASE_URL) {
throw new Error(
'zApp Staking requires environment variable: VITE_STAKING_SUPABASE_URL',
);
}

const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
let [resource, config] = args;

const url = resource.toString();

if (url.startsWith('https://api.coingecko.com:443/')) {
resource = new URL(
url.replace(
'https://api.coingecko.com:443/',
SUPABASE_URL + '/functions/v1/price/',
),
);
}

return await originalFetch(resource, config);
};
3 changes: 3 additions & 0 deletions supabase/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Supabase
.branches
.temp
137 changes: 137 additions & 0 deletions supabase/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "zFi-dapp"

[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000

[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialise the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15

[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100

[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"

[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://localhost"

# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326

[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"

[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true

[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false

# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"

[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false

# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# 4152127777 = "123456"

# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"

# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""

[analytics]
enabled = false
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"

[functions.price]
verify_jwt = false
86 changes: 86 additions & 0 deletions supabase/functions/price/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { serve } from 'https://deno.land/[email protected]/http/server.ts';
import { Redis } from 'https://deno.land/x/[email protected]/mod.ts';

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type',
};

const EXPIRY_TIME = 60 * 10; // 10 minutes

serve(async (req) => {
try {
const redis = new Redis({
url: Deno.env.get('UPSTASH_URL')!,
token: Deno.env.get('UPSTASH_AUTH_TOKEN'),
});

/** The original Coingecko endpoint */
const url = req.url.split('/price/api/v3/')[1];

if (!url) {
// throw
throw new Error('Invalid token price API url');
}

/** The coingecko token ID */
let tokenId;

if (url.startsWith('coins/wilder-world')) {
tokenId = 'wilder-world';
} else if (url.startsWith('coins/ethereum')) {
tokenId = 'ethereum';
} else {
throw new Error(
'Invalid token - this API may only retrieve ETH or WILD prices',
);
}

/*
* Try get the cached response from Redis first
*/

const cachedResponse = await redis.get(tokenId);

if (cachedResponse) {
return new Response(JSON.stringify(cachedResponse), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

/*
* There was no cached response, so lets query the Coingecko API
*/

let data;

try {
const res = await fetch('https://api.coingecko.com/api/v3/' + url);
data = await res.json();
} catch (e) {
throw new Error('Failed to retrieve token price from API');
}

/*
* Cache the response in Redis, and return it
*/

try {
await redis.set(tokenId, JSON.stringify(data), { ex: EXPIRY_TIME });
} catch (e) {
console.warn(`Failed to stash value for ${tokenId} in Redis: `, e);
}

return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (e) {
console.error(e);

return new Response(JSON.stringify({ error: e.message }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
status: 500,
});
}
});
Empty file added supabase/seed.sql
Empty file.