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] adds call and route to index token account balance #3

Merged
merged 8 commits into from
Oct 26, 2023
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dependencies": {
"@fastify/helmet": "^11.1.1",
"@urql/core": "^4.1.3",
"ajv": "^8.12.0",
"axios": "^1.5.1",
"dotenv-expand": "^10.0.0",
"fastify": "^4.23.2",
Expand Down
35 changes: 35 additions & 0 deletions src/helper/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const mercurySession = {
};

const pubKey = "GDUBMXMABE7UOZSGYJ5ONE7UYAEHKK3JOX7HZQGNZ7NYTZPPP4AJ2GQJ";
const tokenBalanceLedgerKey =
"AAAAEAAAAAEAAAABAAAAEQAAAAEAAAACAAAADwAAAAdiYWxhbmNlAAAAAA4AAAAHQmFsYW5jZQAAAAAPAAAAB2FkZHJlc3MAAAAADgAAADhHRFVCTVhNQUJFN1VPWlNHWUo1T05FN1VZQUVIS0szSk9YN0haUUdOWjdOWVRaUFBQNEFKMkdRSg==";

const queryMockResponse = {
[mutation.authenticate]: {
Expand All @@ -53,6 +55,30 @@ const queryMockResponse = {
},
},
},
"query.getAccountBalances": {
edges: [
{
node: {
contractId: "contract-id-1",
keyXdr: tokenBalanceLedgerKey,
valueXdr: "value-xdr",
ledgerTimestamp: "timestamp",
ledger: "1",
entryDurability: "persistent",
},
},
{
node: {
contractId: "contract-id-2",
keyXdr: tokenBalanceLedgerKey,
valueXdr: "value-xdr",
ledgerTimestamp: "timestamp",
ledger: "1",
entryDurability: "persistent",
},
},
],
},
[query.getAccountHistory]: {
eventByContractId: {
edges: [],
Expand Down Expand Up @@ -106,6 +132,15 @@ jest.spyOn(client, "query").mockImplementation((_query: any): any => {
error: null,
});
}
case query.getAccountBalances(tokenBalanceLedgerKey, [
"contract-id-1",
"contract-id-2",
]): {
return Promise.resolve({
data: queryMockResponse["query.getAccountBalances"],
error: null,
});
}
default:
throw new Error("unknown query in mock");
}
Expand Down
12 changes: 12 additions & 0 deletions src/helper/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { StrKey } from "soroban-client";

const isContractId = (contractId: string) => {
try {
StrKey.decodeContract(contractId);
return true;
} catch (error) {
return false;
}
};

export { isContractId };
83 changes: 81 additions & 2 deletions src/route/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import Fastify, { FastifyRequest } from "fastify";
import helmet from "@fastify/helmet";

import { MercuryClient } from "../service/mercury";
import { ajv } from "./validators";
import { isContractId } from "../helper/validate";

const API_VERSION = "v1";

export function initApiServer(mercuryClient: MercuryClient) {
const server = Fastify({
logger: true,
});
server.setValidatorCompiler(({ schema }) => {
return ajv.compile(schema);
});

server.register(helmet, { global: true });
server.register(
Expand Down Expand Up @@ -37,6 +42,41 @@ export function initApiServer(mercuryClient: MercuryClient) {
},
});

instance.route({
method: "GET",
url: "/account-balances/:pub-key",
schema: {
params: {
["pub-key"]: { type: "string" },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to make this optimization now, but might be useful to think about if we want to store things like params and query string values in an enum as we introduce more routes/complexity

},
querystring: {
["contract-ids"]: {
type: "string",
validator: (qStr: string) => qStr.split(",").some(isContractId),
},
},
},
handler: async (
request: FastifyRequest<{
Params: { ["pub-key"]: string };
Querystring: { ["contract-ids"]: string };
}>,
reply
) => {
const pubKey = request.params["pub-key"];
const contractIds = request.query["contract-ids"].split(",");
const { data, error } = await mercuryClient.getAccountBalances(
pubKey,
contractIds
);
if (error) {
reply.code(400).send(error);
} else {
reply.code(200).send(data);
}
},
});

instance.route({
method: "POST",
url: "/subscription/token",
Expand Down Expand Up @@ -64,7 +104,7 @@ export function initApiServer(mercuryClient: MercuryClient) {
reply
) => {
const { contract_id, pub_key } = request.body;
const { data, error } = await mercuryClient.addNewTokenSubscription(
const { data, error } = await mercuryClient.tokenSubscription(
contract_id,
pub_key
);
Expand Down Expand Up @@ -100,7 +140,46 @@ export function initApiServer(mercuryClient: MercuryClient) {
reply
) => {
const { pub_key } = request.body;
const { data, error } = await mercuryClient.addNewAccountSubscription(
const { data, error } = await mercuryClient.accountSubscription(
pub_key
);
if (error) {
reply.code(400).send(error);
} else {
reply.code(200).send(data);
}
},
});

instance.route({
method: "POST",
url: "/subscription/token-balance",
schema: {
body: {
type: "object",
properties: {
contract_id: { type: "string" },
pub_key: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
data: { type: "object" },
},
},
},
},
handler: async (
request: FastifyRequest<{
Body: { pub_key: string; contract_id: string };
}>,
reply
) => {
const { pub_key, contract_id } = request.body;
const { data, error } = await mercuryClient.tokenBalanceSubscription(
contract_id,
pub_key
);
if (error) {
Expand Down
53 changes: 53 additions & 0 deletions src/route/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Ajv, {
AnySchemaObject,
ValidateFunction,
SchemaValidateFunction,
} from "ajv";

const ajv = new Ajv({
removeAdditional: true,
useDefaults: true,
coerceTypes: true,
});

ajv.addKeyword("validator", {
compile: (schema: any, parentSchema: AnySchemaObject) => {
return function validate(data: ValidateFunction) {
if (typeof schema === "function") {
const valid = schema(data);
if (!valid) {
(validate as SchemaValidateFunction).errors = [
{
keyword: "validate",
message: `: ${data} fails validation`,
params: { keyword: "validate" },
},
];
}
return valid;
} else if (
typeof schema === "object" &&
Array.isArray(schema) &&
schema.every((f) => typeof f === "function")
) {
const [f, errorMessage] = schema;
const valid = f(data);
if (!valid) {
(validate as SchemaValidateFunction).errors = [
{
keyword: "validate",
message: ": " + errorMessage(schema, parentSchema, data),
params: { keyword: "validate" },
},
];
}
return valid;
} else {
throw new Error("Invalid definition for custom validator");
}
};
},
errors: true,
} as any);

export { ajv };
31 changes: 30 additions & 1 deletion src/service/mercury/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
queryMockResponse,
pubKey,
} from "../../helper/test-helper";
import { xdr } from "soroban-client";

describe("Mercury Service", () => {
it("can renew a token", async () => {
Expand All @@ -26,9 +27,37 @@ describe("Mercury Service", () => {
});

it("can add new full account subscription", async () => {
const { data } = await mockMercuryClient.addNewAccountSubscription(pubKey);
const { data } = await mockMercuryClient.accountSubscription(pubKey);
expect(pubKey).toEqual(
data?.data.createFullAccountSubscription.fullAccountSubscription.publickey
);
});

it("can build a balance ledger key for a pub key", async () => {
const ledgerKey = mockMercuryClient.tokenBalanceKey(pubKey);
const scVal = xdr.ScVal.fromXDR(
Buffer.from(ledgerKey, "base64")
).value() as xdr.ScVal[];
const hasPubKey = scVal.map((scVal) => {
const inner = scVal.value() as xdr.ScMapEntry[];
return inner.some((v) => {
const mapVal = v.val();
return mapVal.value()?.toString() === pubKey;
});
});
expect(hasPubKey).toBeTruthy();
});

it("can fetch account balances by pub key", async () => {
const contracts = ["contract-id-1", "contract-id-2"];
const { data } = await mockMercuryClient.getAccountBalances(
pubKey,
contracts
);
expect(
data?.data.edges.map(
(node: { node: Record<string, string> }) => node.node.contractId
)
).toEqual(contracts);
});
});
Loading
Loading