Skip to content

Commit

Permalink
api: port to new nx workspace
Browse files Browse the repository at this point in the history
  • Loading branch information
0x0ece committed May 8, 2024
1 parent 56d7647 commit 883b0a0
Show file tree
Hide file tree
Showing 19 changed files with 11,610 additions and 11,724 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/pr_test_api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies and run tests
- name: Install dependencies
run: pnpm install

- name: Run tests
shell: bash
run: |
cd api_v0/ && pnpm install && pnpm run test
pnpm api-test
6 changes: 3 additions & 3 deletions anchor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./glamExports";
export * from "./offchain";
// export * from "./models";
// export * from "./clientConfig";
// export * from "./client";
export * from "./models";
export * from "./clientConfig";
export * from "./client";
File renamed without changes.
3 changes: 3 additions & 0 deletions api/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
runtime: nodejs20
env_variables:
SOLANA_RPC: "https://api.devnet.solana.com"
File renamed without changes
File renamed without changes.
180 changes: 178 additions & 2 deletions api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,193 @@
*/

import express, { Express, Request, Response } from "express";
import * as cors from "cors";
import * as path from "path";
import { Connection, PublicKey } from "@solana/web3.js";
import { AnchorProvider } from "@coral-xyz/anchor";

import { createCanvas, loadImage } from "canvas";
import {
PythHttpClient,
PythCluster,
getPythClusterApiUrl,
getPythProgramKeyForCluster
} from "@pythnetwork/client";

import { GlamClient } from "@glam/anchor";
import { validatePubkey } from "./validation";
import { priceHistory, fundPerformance } from "./prices";
import { openfunds } from "./openfunds";

const BASE_URL = "https://api.glam.systems";
const SOLANA_RPC = process.env.SOLANA_RPC || "http://localhost:8899";

const app: Express = express();

const connection = new Connection(SOLANA_RPC, "confirmed");
const provider = new AnchorProvider(connection, null, {
commitment: "confirmed"
});
const client = new GlamClient({ provider });

const PYTHNET_CLUSTER_NAME: PythCluster = "pythnet";
const pythClient = new PythHttpClient(
new Connection(getPythClusterApiUrl(PYTHNET_CLUSTER_NAME)),
getPythProgramKeyForCluster(PYTHNET_CLUSTER_NAME),
"confirmed"
);

app.use("/assets", express.static(path.join(__dirname, "assets")));

app.get("/api", (req: Request, res: Response) => {
res.send({ message: "Welcome to api!" });
res.send({ message: "Welcome to Glam!" });
});

app.get("/openfunds", async (req, res) => {
return openfunds(
req.query.funds.split(","),
req.query.template,
req.query.format,
client,
res
);
});

app.get("/openfunds/:pubkey.:ext", async (req, res) => {
return openfunds([req.params.pubkey], "auto", req.params.ext, client, res);
});

app.get("/openfunds/:pubkey", async (req, res) => {
return openfunds([req.params.pubkey], "auto", "json", client, res);
});

app.get("/prices", async (req, res) => {
const data = await pythClient.getData();
res.set("content-type", "application/json");
res.send(
JSON.stringify({
btc: data.productPrice.get("Crypto.BTC/USD").price,
eth: data.productPrice.get("Crypto.ETH/USD").price,
sol: data.productPrice.get("Crypto.SOL/USD").price,
usdc: data.productPrice.get("Crypto.USDC/USD").price
})
);
});

app.get("/fund/:pubkey/perf", async (req, res) => {
const { w_btc = 0.4, w_eth = 0, w_sol = 0.6 } = req.query;
// TODO: validate input
// TODO: Should we fetch weights from blockchain, or let client side pass them in?
// Client side should have all fund info including assets and weights
console.log(`btcWeight: ${w_btc}, ethWeight: ${w_eth}, solWeight: ${w_sol}`);
const { timestamps, closingPrices: ethClosingPrices } = await priceHistory(
"Crypto.ETH/USD"
);
const { closingPrices: btcClosingPrices } = await priceHistory(
"Crypto.BTC/USD"
);
const { closingPrices: solClosingPrices } = await priceHistory(
"Crypto.SOL/USD"
);
// const { closingPrices: usdcClosingPrices } = await priceHistory(
// "Crypto.USDC/USD"
// );
const { weightedChanges, btcChanges, ethChanges, solChanges } =
fundPerformance(
w_btc,
btcClosingPrices,
w_eth,
ethClosingPrices,
w_sol,
solClosingPrices
);
res.set("content-type", "application/json");
res.send(
JSON.stringify({
timestamps,
// usdcClosingPrices,
fundPerformance: weightedChanges,
btcPerformance: btcChanges,
ethPerformance: ethChanges,
solPerformance: solChanges
})
);
});

app.get("/metadata/:pubkey", async (req, res) => {
const key = validatePubkey(req.params.pubkey);
if (!key) {
return res.sendStatus(404);
}

// TODO: Fetch name and symbol from blockchain

const imageUri = `${BASE_URL}/image/${req.params.pubkey}.png`;
res.set("content-type", "application/json");
res.send(
JSON.stringify({
name: "name_placeholder",
symbol: "symbol_placeholder",
description: "",
external_url: "https://glam.systems",
image: imageUri
})
);
});

app.get("/image/:pubkey.png", async (req, res) => {
// convert pubkey from base58 to bytes[32]
const key = validatePubkey(req.params.pubkey);
if (!key) {
return res.sendStatus(404);
}

// fetch params from the key bytes
const angle = 6.28 * (key[0] / 256.0); // between 0.0 and 2*pi
const alpha = key[1] / 256.0 / 4 + 0.75; // between 0.5 and 1.0
const colorR = key[2]; // between 0 and 255
const colorG = key[3];
const colorB = key[4];

const fullSize = 512; // size of the input
const size = fullSize / 2; // size of the output
const offset = size / 2; // offset for rotation/translation

function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? "0" + hex : hex;
}

function rgbToHex(r, g, b) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}

// canvas
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");

// base color
ctx.fillStyle = rgbToHex(colorR, colorG, colorB);
ctx.fillRect(0, 0, size, size);

// rotation (relative to image center)
ctx.translate(offset, offset);
ctx.rotate(angle);
ctx.translate(-offset, -offset);

// render the image full size, on half size canvas
// so that we don't see broken corners
ctx.globalAlpha = alpha;
const image = await loadImage(path.join(__dirname, "assets/glam.png"));
ctx.drawImage(image, -offset, -offset, fullSize, fullSize);

// return the image
const buffer = canvas.toBuffer("image/png");
res.set("content-type", "image/png");
res.send(buffer);
});

const port = process.env.PORT || 3333;
const port = process.env.PORT || 8080;
const server = app.listen(port, () => {
console.log(`Listening at http://localhost:${port}/api`);
});
Expand Down
124 changes: 124 additions & 0 deletions api/src/openfunds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/

import * as ExcelJS from "exceljs";
import * as util from "util";
import { write, writeToBuffer } from "@fast-csv/format";
import { parseString } from "@fast-csv/parse";

import { validatePubkey } from "./validation";

const openfundsKeyFromField = (f) => {
const words = f.field.replace("-", "").split(" ");
return [words[0].toLowerCase(), ...words.splice(1)].join("");
};

const openfundsGetTemplate = async (template) => {
// https://docs.google.com/spreadsheets/d/1PQFTn1iV90OkZqzdvwaOgTceltGnsEJxuxiCTdo_5Yo/edit#gid=0
const templateUrl =
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSH59SKOZv_mrXjfBUKCqK75sGj-yIXSLOkw4MMMnxMVSCZFodvOTfvTIRrymeMAOG2EBTnG5eN_ImV/pub?output=csv";
const res = await fetch(templateUrl);

const templateCsv = await new Promise(async (resolve, reject) => {
let templateCsv = [];
parseString(await res.text())
.on("data", (row) => {
templateCsv.push(row);
})
.on("end", (rowCount: number) => {
resolve(templateCsv);
});
});
const codes = templateCsv[0].slice(1).filter((x) => !!x);
const fields = templateCsv[1].slice(1).filter((x) => !!x);
const tags = templateCsv[2].slice(1).filter((x) => !!x);
const templates = templateCsv[3].slice(1).filter((x) => !!x);
const templateMap = codes.map((code, i) => ({
code,
field: fields[i],
tag: tags[i],
template: templates[i]
}));
// .filter((obj) => obj.template == "basic");
// console.log(templateMap);
return templateMap;
};

const openfundsCsvRows = (model) => {
return model.shareClasses.map((shareClass) => ({
...model,
...model.company,
...model.fundManagers[0],
...shareClass
}));
};

const openfundsApplyCsvTemplate = async (models, template) => {
const fields = await openfundsGetTemplate(template);
return [
fields.map((f) => f.code),
fields.map((f) => f.field),
// fields.map((f) => openfundsKeyFromField(f)), // row with keys, just to debug
...models.flatMap((m) =>
openfundsCsvRows(m).map((row) =>
fields.map((f) => row[openfundsKeyFromField(f)])
)
)
];
};

export const openfunds = async (funds, template, format, client, res) => {
console.log(`openfunds funds=${funds} template=${template} format=${format}`);
let models;
try {
// validate & fetch funds in parallel with Promise.all
// if anything errors, return 404 + one of the funds that errored
models = await Promise.all(
funds.map(
(fund) =>
new Promise(async (resolve, reject) => {
const rejectErr = `Not Found: ${fund}`;
// validate key
const key = validatePubkey(fund);
if (!key) {
reject(rejectErr);
}
// fetch fund
try {
resolve(await client.fetchFund(key));
} catch (err) {
reject(rejectErr);
}
})
)
);
} catch (err) {
return res.status(404).send(err); // `Not Found: ${fund}`
}

console.log(util.inspect(models, false, null));

switch (format.toLowerCase()) {
case "csv": {
const csv = await openfundsApplyCsvTemplate(models, template);
res.setHeader("content-type", "text/csv");
return res.send(await writeToBuffer(csv));
break;
}
case "xls":
case "xlsx": {
const csv = await openfundsApplyCsvTemplate(models, template);
const workbook = new ExcelJS.Workbook();
const worksheet = await workbook.csv.read(write(csv));
res.setHeader(
"content-type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
return res.send(await workbook.xlsx.writeBuffer());
break;
}
}
res.send(JSON.stringify(models));
};
21 changes: 11 additions & 10 deletions api_v0/prices.js → api/src/prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Fetch price data from benchmarks.pyth.network
* Rate limit: https://docs.pyth.network/benchmarks/rate-limits
*/
const priceHistory = async (symbol) => {
export const priceHistory = async (symbol) => {
const emptyResult = { timestamps: null, closingPrices: null };
if (
![
"Crypto.ETH/USD",
Expand All @@ -12,17 +13,17 @@ const priceHistory = async (symbol) => {
].includes(symbol)
) {
console.error("Invalid symbol", symbol);
return [];
return emptyResult;
}
const tsEnd = Math.floor(Date.now() / 1000);
const tsStart = tsEnd - 60 * 60 * 24 * 30; // 30 days ago

const queryParams = {
symbol,
resolution: "1D",
from: tsStart,
to: tsEnd
};
const queryParams = [
["symbol", symbol],
["resolution", "1D"],
["from", tsStart],
["to", tsEnd]
];
const baseUrl =
"https://benchmarks.pyth.network/v1/shims/tradingview/history";
const queryString = new URLSearchParams(queryParams).toString();
Expand All @@ -37,13 +38,13 @@ const priceHistory = async (symbol) => {
const { t: timestamps, c: closingPrices } = await response.json();
return { timestamps, closingPrices };
}
return [];
return emptyResult;
};

/**
* Percent change in the last X days
*/
const fundPerformance = (
export const fundPerformance = (
btcWeight,
btcPrices,
ethWeight,
Expand Down
Loading

0 comments on commit 883b0a0

Please sign in to comment.