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(arweave): add arweave client for persisting/retrieving data #547

4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
nicholaspai marked this conversation as resolved.
Show resolved Hide resolved
- run: yarn install
- name: Run local arweave node
run: yarn test:run:arweave &
- run: yarn test
env:
NODE_URL_1: ${{ secrets.NODE_URL_1 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ cache
yarn-error.log
yarn-debug.log*
gasReporterOutput.json
logs
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16
18
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"copy-abi": "abi=utils/abi/contracts; dstdir=\"./dist/${DIR}/${abi}\"; mkdir -p \"${dstdir}\"; cp ./src/${abi}/*.json \"${dstdir}\"",
"test": "hardhat test",
"test:watch": "hardhat watch test",
"test:run:arweave": "npx -y arlocal",
"lint": "eslint --fix src test e2e && yarn prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"e2e/**/*.ts\"",
"lint-check": "eslint src test e2e && yarn prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"e2e/**/*.ts\"",
"prepare": "yarn build && husky install",
Expand Down Expand Up @@ -103,6 +104,7 @@
"@pinata/sdk": "^2.1.0",
"@types/mocha": "^10.0.1",
"@uma/sdk": "^0.34.1",
"arweave": "^1.14.4",
"axios": "^0.27.2",
"big-number": "^2.0.0",
"decimal.js": "^10.3.1",
Expand Down
140 changes: 140 additions & 0 deletions src/caching/Arweave/ArweaveClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Arweave from "arweave";
import { JWKInterface } from "arweave/node/lib/wallet";
import { ethers } from "ethers";
import winston from "winston";
import { isDefined, jsonReplacerWithBigNumbers, parseWinston } from "../../utils";
import { Struct, is } from "superstruct";
import { ARWEAVE_TAG_APP_NAME } from "../../constants";

export class ArweaveClient {
private client: Arweave;

public constructor(
private arweaveJWT: JWKInterface,
private logger: winston.Logger,
gatewayURL = "arweave.net",
protocol = "https",
port = 443
) {
this.client = new Arweave({
host: gatewayURL,
port,
protocol,
timeout: 20000,
logging: false,
});
this.logger.info("Arweave client initialized");
}

/**
* Stores an arbitrary record in the Arweave network. The record is stored as a JSON string and uses
* JSON.stringify to convert the record to a string. The record has all of its big numbers converted
* to strings for convenience.
* @param value The value to store
* @param topicTag An optional topic tag to add to the transaction
* @returns The transaction ID of the stored value
* @
*/
async set(value: Record<string, unknown>, topicTag?: string | undefined): Promise<string | undefined> {
const transaction = await this.client.createTransaction(
{ data: JSON.stringify(value, jsonReplacerWithBigNumbers) },
this.arweaveJWT
);

// Add tags to the transaction
transaction.addTag("Content-Type", "application/json");
james-a-morris marked this conversation as resolved.
Show resolved Hide resolved
transaction.addTag("App-Name", ARWEAVE_TAG_APP_NAME);
if (isDefined(topicTag)) {
transaction.addTag("Topic", topicTag);
}

// Sign the transaction
await this.client.transactions.sign(transaction, this.arweaveJWT);
// Send the transaction
const result = await this.client.transactions.post(transaction);
james-a-morris marked this conversation as resolved.
Show resolved Hide resolved
this.logger.debug({
at: "ArweaveClient:set",
message: `Arweave transaction posted with ${transaction.id}`,
});
// Ensure that the result is successful
if (result.status !== 200) {
this.logger.error({
at: "ArweaveClient:set",
message: `Arweave transaction failed with ${transaction.id}`,
result,
address: await this.getAddress(),
balance: (await this.getBalance()).toString(),
});
throw new Error("Server failed to receive arweave transaction");
}
return transaction.id;
}

/**
* Retrieves a record from the Arweave network. The record is expected to be a JSON string and is
* parsed using JSON.parse. All numeric strings are converted to big numbers for convenience.
* @param transactionID The transaction ID of the record to retrieve
* @param structValidator An optional struct validator to validate the retrieved value. If the value does not match the struct, null is returned.
* @returns The record if it exists, otherwise null
*/
async get<T>(transactionID: string, validator: Struct<T>): Promise<T | null> {
const rawData = await this.client.transactions.getData(transactionID, { decode: true, string: true });
if (!rawData) {
return null;
}
// Parse the retrieved data - if it is an Uint8Array, it is a buffer and needs to be converted to a string
const data = JSON.parse(typeof rawData === "string" ? rawData : Buffer.from(rawData).toString("utf-8"));
// Ensure that the result is successful. If it is not, the retrieved value is not our expected type
// but rather a {status: string, statusText: string} object. We can detect that and return null.
if (data.status === 400) {
return null;
}
// If the validator does not match the retrieved value, return null and log a warning
if (!is(data, validator)) {
this.logger.warn("Retrieved value from Arweave does not match the expected type");
return null;
}
return data;
}

/**
* Retrieves the metadata of a transaction
* @param transactionID The transaction ID of the record to retrieve
* @returns The metadata of the transaction if it exists, otherwise null
*/
async getMetadata(transactionID: string): Promise<Record<string, string> | null> {
const transaction = await this.client.transactions.get(transactionID);
if (!isDefined(transaction)) {
return null;
}
const tags = Object.fromEntries(
transaction.tags.map((tag) => [
tag.get("name", { decode: true, string: true }),
tag.get("value", { decode: true, string: true }),
])
);
return {
contentType: tags["Content-Type"],
appName: tags["App-Name"],
topic: tags.Topic,
};
}

/**
* Returns the address of the signer of the JWT
* @returns The address of the signer in this client
*/
getAddress(): Promise<string> {
return this.client.wallets.jwkToAddress(this.arweaveJWT);
}

/**
* The balance of the signer
* @returns The balance of the signer in winston units
*/
async getBalance(): Promise<ethers.BigNumber> {
const address = await this.getAddress();
const balanceInFloat = await this.client.wallets.getBalance(address);
return parseWinston(balanceInFloat);
}
}
1 change: 1 addition & 0 deletions src/caching/Arweave/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./ArweaveClient";
1 change: 1 addition & 0 deletions src/caching/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./IPFS";
export * from "./Arweave";
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export const HUBPOOL_CHAIN_ID = 1;
// List of versions where certain UMIP features were deprecated
export const TRANSFER_THRESHOLD_MAX_CONFIG_STORE_VERSION = 1;

// A hardcoded identifier used, by default, to tag all Arweave records.
export const ARWEAVE_TAG_APP_NAME = "across-protocol";

/**
* A default list of chain Ids that the protocol supports. This is outlined
* in the UMIP (https://github.com/UMAprotocol/UMIPs/pull/590) and is used
Expand Down
18 changes: 18 additions & 0 deletions src/utils/FormattingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,21 @@ export const ConvertDecimals = (fromDecimals: number, toDecimals: number): ((amo
return amount.mul(toBN("10").pow(toBN((-1 * diff).toString())));
};
};

/**
* Converts a numeric decimal-inclusive string to winston, the base unit of Arweave
* @param numericString The numeric string to convert
* @returns The winston representation of the numeric string as a BigNumber
*/
export function parseWinston(numericString: string): ethers.BigNumber {
return ethers.utils.parseUnits(numericString, 12);
}

/**
* Converts a winston value to a numeric string
* @param winstonValue The winston value to convert
* @returns The numeric string representation of the winston value
*/
export function formatWinston(winstonValue: ethers.BigNumber): string {
return ethers.utils.formatUnits(winstonValue, 12);
}
8 changes: 8 additions & 0 deletions src/utils/JSONUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BigNumber } from "ethers";
import { isDefined } from "./TypeGuards";

/**
* This function converts a JSON string into a JSON object. The caveat is that if
Expand Down Expand Up @@ -50,6 +51,13 @@ export function jsonReplacerWithBigNumbers(_key: string, value: unknown): unknow
if (BigNumber.isBigNumber(value)) {
return value.toString();
}
// There's a legacy issues that returns BigNumbers as { type: "BigNumber", hex: "0x..." }
// so we need to check for that as well.
const recordValue = value as { type: string; hex: string };
james-a-morris marked this conversation as resolved.
Show resolved Hide resolved
if (recordValue.type === "BigNumber" && isDefined(recordValue.hex)) {
return BigNumber.from(recordValue.hex).toString();
}
// Return the value as is
return value;
}

Expand Down
157 changes: 157 additions & 0 deletions test/arweaveClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import Arweave from "arweave";
import { JWKInterface } from "arweave/node/lib/wallet";
import axios from "axios";
import { expect } from "chai";
import winston from "winston";
import { ArweaveClient } from "../src/caching";
import { parseWinston, toBN } from "../src/utils";
import { object, string } from "superstruct";
import { ARWEAVE_TAG_APP_NAME } from "../src/constants";

const INITIAL_FUNDING_AMNT = "5000000000";
const LOCAL_ARWEAVE_NODE = {
protocol: "http",
host: "localhost",
port: 1984,
};
const LOCAL_ARWEAVE_URL = `${LOCAL_ARWEAVE_NODE.protocol}://${LOCAL_ARWEAVE_NODE.host}:${LOCAL_ARWEAVE_NODE.port}`;

const mineBlock = () => axios.get(`${LOCAL_ARWEAVE_URL}/mine`);

describe("ArweaveClient", () => {
let jwk: JWKInterface;
let client: ArweaveClient;
// Before running any of the tests, we need to fund the address with some AR
// so that we can post to our testnet node
before(async () => {
// Generate a new JWK for our tests
jwk = await Arweave.init({}).wallets.generate();
// Resolve the address of the JWK
const address = await Arweave.init({}).wallets.jwkToAddress(jwk);
// Call into the local arweave node to fund the address
await axios.get(`${LOCAL_ARWEAVE_URL}/mint/${address}/${INITIAL_FUNDING_AMNT}`);
// Wait for the transaction to be mined
await mineBlock();
});

beforeEach(() => {
// Create a new Arweave client
client = new ArweaveClient(
jwk,
// Define default winston logger
winston.createLogger({
level: "info",
format: winston.format.json(),
defaultMeta: { service: "arweave-client" },
transports: [new winston.transports.Console()],
}),
LOCAL_ARWEAVE_NODE.host,
LOCAL_ARWEAVE_NODE.protocol,
LOCAL_ARWEAVE_NODE.port
);
});

it(`should have ${INITIAL_FUNDING_AMNT} initial AR in the address`, async () => {
const balance = (await client.getBalance()).toString();
expect(balance.toString()).to.equal(parseWinston(INITIAL_FUNDING_AMNT).toString());
});

it("should be able to set a basic record and view it on the network", async () => {
const value = { test: "value" };
const txID = await client.set(value);
console.log(txID);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const retrievedValue = await client.get(txID!, object());
expect(retrievedValue).to.deep.equal(value);
});

it("should successfully set a record with a BigNumber", async () => {
const value = { test: "value", bigNumber: toBN("1000000000000000000") };
const txID = await client.set(value);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const retrievedValue = await client.get(txID!, object());

const expectedValue = { test: "value", bigNumber: "1000000000000000000" };
expect(retrievedValue).to.deep.equal(expectedValue);
});

it("should fail to get a non-existent record", async () => {
const retrievedValue = await client.get("non-existent", object());
expect(retrievedValue).to.be.null;
});

it("should validate the record with a struct validator", async () => {
james-a-morris marked this conversation as resolved.
Show resolved Hide resolved
const value = { test: "value" };
const txID = await client.set(value);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const validatorStruct = object({ test: string() });

const retrievedValue = await client.get(txID!, validatorStruct);
expect(retrievedValue).to.deep.equal(value);
});

it("should fail validation of the record with a struct validator that doesn't match the returned type", async () => {
const value = { test: "value" };
const txID = await client.set(value);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const validatorStruct = object({ invalid: string() });

const retrievedValue = await client.get(txID!, validatorStruct);
expect(retrievedValue).to.eq(null);
});

it("should retrieve the metadata of a transaction", async () => {
const value = { test: "value" };
const txID = await client.set(value);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const metadata = await client.getMetadata(txID!);
expect(metadata).to.deep.equal({
contentType: "application/json",
appName: ARWEAVE_TAG_APP_NAME,
topic: undefined,
});
});

it("should retrieve the metadata of a transaction with a topic tag", async () => {
const value = { test: "value" };
const topicTag = "test-topic";
const txID = await client.set(value, topicTag);
expect(txID).to.not.be.undefined;

// Wait for the transaction to be mined
await mineBlock();
await mineBlock();

const metadata = await client.getMetadata(txID!);
expect(metadata).to.deep.equal({
contentType: "application/json",
appName: ARWEAVE_TAG_APP_NAME,
topic: topicTag,
});
});
});
Loading
Loading