Gasless Uploading With Server-Side Signing
Server-side signing is a method to allow you to sign (and pay) for your users' data securely (without exposing your private key). It is a form of gasless transactions.
Server-side signing works in 4 main steps:
- The client requests the required information from the server (mainly public key).
- The client transfers the minimum amount of data required for signing (known as the signature info) to a server (which has access to the private key).
- The server then signs this data and returns the resulting signature to the client.
- The client then inserts this signature into their data, resulting in a signed transaction identical to if the client had access to the private key.
Supported Currencies
Currently, server-side signing is supported for the following:
- Ethereum
- Matic
- BNB
- Fantom
- Avalanche
- Boba-Eth
- Arbitrum
- Chainlink
- Boba
- Solana
Getting Started
The quickest way to get started is to clone our example repository (opens in a new tab) which supports gasless uploads from both EVM and Solana chains.
API Routes
The example app (opens in a new tab) exposes three API routes, you can pick and choose which to use based on your own app design.
publicKey.ts
The route publicKey.ts
returns the public key for the server's wallet. This is the first route called by the client.
Solana-Based Wallets
import { TypedEthereumSigner } from "arbundles";
import { NextResponse } from "next/server";
import Irys from "@irys/sdk";
/**
* @returns The server's public key.
*/
async function serverInit(): Promise<Buffer> {
const key = process.env.PRIVATE_KEY_SOL;
const token = "solana";
const providerUrl = ""; // Optional provider URL
const serverIrys = new Irys({
network: "mainnet",
token, // Token used for payment and signing
key,
config: { providerUrl }, // Optional
});
const publicKey = serverIrys.tokenConfig.getSigner().publicKey;
return publicKey;
}
export async function GET(req: Request) {
return NextResponse.json({ pubKey: (await serverInit()).toString("hex") });
}
EVM-Based
import { TypedEthereumSigner } from "arbundles";
import { NextResponse } from "next/server";
/**
* @returns The server's public key.
*/
async function serverInit(): Promise<Buffer> {
const key = process.env.PRIVATE_KEY_EVM; // your private key;
if (!key) throw new Error("Private key is undefined!");
const signer = new TypedEthereumSigner(key);
return signer.publicKey;
}
export async function GET(req: Request) {
return NextResponse.json({ pubKey: (await serverInit()).toString("hex") });
}
signData.ts
The route signData.ts
signs the data provided using the server's private key. This is the second route called by the client.
Solana-Based Wallets
import type { NextApiRequest, NextApiResponse } from "next";
import { TypedEthereumSigner } from "arbundles";
import { NextResponse } from "next/server";
import Irys from "@irys/sdk";
import SolanaSigner from "arbundles/build/web/esm/src/signing/chains/SolanaSigner";
/**
*
* @returns A signed version of the data, signatureData, as sent by the client.
*/
async function signDataOnServer(signatureData: Buffer): Promise<Buffer> {
const key = process.env.PRIVATE_KEY_SOL;
const token = "solana";
const network = "mainnet";
const providerUrl = ""; // Optional provider URL
const serverIrys = new Irys({
network, // "mainnet"
token, // Token used for payment and signing
key: key,
config: { providerUrl }, // Optional
});
const encodedMessage = Buffer.from(signatureData);
if (!key) throw new Error(`missing required solana private key`);
// the client now uses HexSolanaSigner instead of SolanaSigner, so we have to use the SolanaSigner directly so the signature data isn't hex converted twice.
const signature = await new SolanaSigner(key).sign(encodedMessage);
const isValid = await HexInjectedSolanaSigner.verify(
serverIrys.tokenConfig.getPublicKey() as Buffer,
signatureData,
signature,
);
console.log("is tx valid?", isValid);
return Buffer.from(signature);
}
async function readFromStream(stream: ReadableStream): Promise<string> {
const reader = stream.getReader();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += new TextDecoder().decode(value);
}
return result;
}
export async function POST(req: Request) {
//@ts-ignore
const rawData = await readFromStream(req.body);
const body = JSON.parse(rawData);
const signatureData = Buffer.from(body.signatureData, "hex");
const signature = await signDataOnServer(signatureData);
return NextResponse.json({ signature: signature.toString("hex") });
}
EVM-Based
import type { NextApiRequest, NextApiResponse } from "next";
import { TypedEthereumSigner } from "arbundles";
import { NextResponse } from "next/server";
/**
*
* @returns A signed version of the data, signatureData, as sent by the client.
*/
async function signDataOnServer(signatureData: Buffer): Promise<Buffer> {
const key = process.env.PRIVATE_KEY_EVM;
if (!key) throw new Error("Private key is undefined!");
const signer = new TypedEthereumSigner(key);
return Buffer.from(await signer.sign(signatureData));
}
async function readFromStream(stream: ReadableStream): Promise<string> {
const reader = stream.getReader();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += new TextDecoder().decode(value);
}
return result;
}
export async function POST(req: Request) {
//@ts-ignore
const rawData = await readFromStream(req.body);
const body = JSON.parse(rawData);
const signatureData = Buffer.from(body.signatureData, "hex");
const signature = await signDataOnServer(signatureData);
return NextResponse.json({ signature: signature.toString("hex") });
}
lazyFund.ts
The route lazyFund.ts
is an optional route used for lazy-funding uploads. Some projects using server-side signing prefer to do upfront funding where they transfer over a budget of tokens first and then slowly use those to pay for uploads. If you're using upfront funding, you can omit this step.
To perform lazy-funding of uploads, pass the exact number of bytes you want to fund to this route. The route will compute the current cost to upload those bytes and fund it using the server's private key.
Solana-Based Wallets
import Irys from "@irys/sdk";
import getRpcUrl from "@/app/utils/getRpcUrl";
import { NextResponse } from "next/server";
import { ReadableStream } from "stream/web";
/**
* Given a file of the specified size, get the cost to upload, then fund a node that amount
* @param filesize The size of a file to fund for
* @returns
*/
async function lazyFund(filesize: string): Promise<string> {
console.log("lazyFund SOL");
const key = process.env.PRIVATE_KEY_SOL;
const token = "solana";
const url = process.env.NEXT_PUBLIC_NODE || "";
const providerUrl = ""; // Optional provider URL
const serverIrys = new Irys({
url, // URL of the node you want to connect to
token, // Token used for payment and signing
key: key,
config: { providerUrl }, // Optional
});
const price = await serverIrys.getPrice(parseInt(filesize));
console.log("lazyFund SOL price=", price);
const balance = await serverIrys.getLoadedBalance();
console.log("lazyFund SOL balance=", balance);
let fundTx;
if (price.isGreaterThanOrEqualTo(balance)) {
console.log("Funding node.");
fundTx = await serverIrys.fund(price);
console.log("Successfully funded fundTx=", fundTx);
} else {
console.log("Funding not needed, balance sufficient.");
}
// return the transaction id
return fundTx?.id || "";
}
async function readFromStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) return "";
const reader = stream.getReader();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += new TextDecoder().decode(value);
}
return result;
}
export async function POST(req: Request) {
//@ts-ignore
const rawData = await readFromStream(req.body as ReadableStream<Uint8Array> | null);
const body = JSON.parse(rawData);
const fundTx = await lazyFund(body);
return NextResponse.json({ txResult: fundTx });
}
EVM-Based
import Irys from "@irys/sdk";
import getRpcUrl from "@/app/utils/getRpcUrl";
import { NextResponse } from "next/server";
import { ReadableStream } from "stream/web";
/**
* Given a file of the specified size, get the cost to upload, then fund a node that amount
* @param filesize The size of a file to fund for
* @returns
*/
async function lazyFund(filesize: string): Promise<string> {
const key = process.env.PRIVATE_KEY_EVM;
const token = process.env.NEXT_PUBLIC_TOKEN || "";
const providerUrl = getRpcUrl(token || "");
const serverIrys = new Irys({
network: "mainnet",
token, // Token used for payment and signing
key: key,
config: { providerUrl }, // Optional RPC URL
});
console.log(
"serverIrysPubKey",
//@ts-ignore
serverIrys.tokenConfigConfig.getPublicKey().toJSON(),
);
const price = await serverIrys.getPrice(parseInt(filesize));
const balance = await serverIrys.getLoadedBalance();
let fundTx;
if (price.isGreaterThanOrEqualTo(balance)) {
console.log("Funding node.");
fundTx = await serverIrys.fund(price);
console.log("Successfully funded fundTx=", fundTx);
} else {
console.log("Funding not needed, balance sufficient.");
}
// return the transaction id
return fundTx?.id || "";
}
async function readFromStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) return "";
const reader = stream.getReader();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += new TextDecoder().decode(value);
}
return result;
}
export async function POST(req: Request) {
//@ts-ignore
const rawData = await readFromStream(req.body as ReadableStream<Uint8Array> | null);
const body = JSON.parse(rawData);
const fundTx = await lazyFund(body);
return NextResponse.json({ txResult: fundTx });
}
Client-side
On the client-side there is a single utility function that calls either the EVM or SOL routes.
import { WebIrys } from "@irys/sdk";
import getIrys from "@/utils/getIrys";
type Tag = {
name: string;
value: string;
};
const gaslessFundAndUploadEVM = async (selectedFile: File, tags: Tag[]): Promise<string> => {
// obtain the server's public key
const pubKeyRes = (await (await fetch("/api/publicKeyEVM")).json()) as unknown as {
pubKey: string;
};
const pubKey = Buffer.from(pubKeyRes.pubKey, "hex");
// Create a provider - this mimics the behaviour of the injected provider, i.e metamask
const provider = {
// For EVM wallets
getPublicKey: async () => {
return pubKey;
},
getSigner: () => {
return {
getAddress: () => pubKey.toString(), // pubkey is address for TypedEthereumSigner
_signTypedData: async (
_domain: never,
_types: never,
message: { address: string; "Transaction hash": Uint8Array },
) => {
const convertedMsg = Buffer.from(message["Transaction hash"]).toString("hex");
console.log("convertedMsg: ", convertedMsg);
const res = await fetch("/api/signDataEVM", {
method: "POST",
body: JSON.stringify({ signatureData: convertedMsg }),
});
const { signature } = await res.json();
const bSig = Buffer.from(signature, "hex");
// Pad & convert so it's in the format the signer expects to have to convert from.
const pad = Buffer.concat([Buffer.from([0]), Buffer.from(bSig)]).toString("hex");
return pad;
},
};
},
_ready: () => {},
};
console.log("Got provider=", provider);
// You can delete the lazyFund route if you're prefunding all uploads
// 2. then pass the size to the lazyFund API route
const fundTx = await fetch("/api/lazyFundEVM", {
method: "POST",
body: selectedFile.size.toString(),
});
// Create a new WebIrys object using the provider created with server info.
const token = process.env.NEXT_PUBLIC_TOKEN || "";
const wallet = { name: "ethersv5", provider: provider };
const irys = new WebIrys({
network: "mainnet",
token,
wallet,
});
const w3signer = await provider.getSigner();
const address = (await w3signer.getAddress()).toLowerCase();
await irys.ready();
console.log("Uploading...");
const tx = await irys.uploadFile(selectedFile, {
tags,
});
console.log(`Uploaded successfully. https://gateway.irys.xyz/${tx.id}`);
return tx.id;
};
const gaslessFundAndUploadSOL = async (selectedFile: File, tags: Tag[]): Promise<string> => {
// Obtain the server's public key
const pubKeyRes = (await (await fetch("/api/publicKeySOL")).json()) as unknown as {
pubKey: string;
};
const pubKey = Buffer.from(pubKeyRes.pubKey, "hex");
console.log("got pubKey=", pubKey);
// Create a provider
const provider = {
publicKey: {
toBuffer: () => pubKey,
byteLength: 32,
},
signMessage: async (message: Uint8Array) => {
let convertedMsg = Buffer.from(message).toString("hex");
const res = await fetch("/api/signDataSOL", {
method: "POST",
body: JSON.stringify({
signatureData: convertedMsg,
}),
});
const { signature } = await res.json();
const bSig = Buffer.from(signature, "hex");
return bSig;
},
};
// You can delete the lazyFund route if you're prefunding all uploads
const fundTx = await fetch("/api/lazyFundSOL", {
method: "POST",
body: selectedFile.size.toString(),
});
// Create a new WebIrys object using the provider created with server info.
const url = process.env.NEXT_PUBLIC_NODE || "";
const wallet = { name: "solana", provider: provider };
const irys = new WebIrys({
network: "mainnet", // "mainnet"
token: "solana",
wallet,
});
await irys.ready();
console.log("WebIrys=", irys);
console.log("Uploading...");
const tx = await irys.uploadFile(selectedFile, {
tags,
});
console.log(`Uploaded successfully. https://gateway.irys.xyz/${tx.id}`);
return tx.id;
};
/**
* Uploads the selected file and tags after funding if necessary.
*
* @param {File} selectedFile - The file to be uploaded.
* @param {Tag[]} tags - An array of tags associated with the file.
* @returns {Promise<string>} - The transaction ID of the upload.
*/
const gaslessFundAndUpload = async (selectedFile: File, tags: Tag[], blockchain: "EVM" | "SOL"): Promise<string> => {
let txId = "";
switch (blockchain) {
case "EVM":
txId = await gaslessFundAndUploadEVM(selectedFile, tags);
break;
case "SOL":
txId = await gaslessFundAndUploadSOL(selectedFile, tags);
break;
default:
throw new Error("Unsupported blockchain");
}
return txId;
};
export default gaslessFundAndUpload;