A light Turbo client for Node and browsers. Sign ANS-104 data items with an Arweave JWK, a Solana key or a Solana wallet, pay for them, and upload them to a Turbo upload service.
Zero runtime dependencies. Not "few". Zero. dependencies,
peerDependencies and optionalDependencies are all empty, and a test in the
shipped suite fails the build if that ever changes. The Node build imports
node:crypto and node:buffer; the browser build imports nothing at all.
npm install @ardrive/turbo-uploadRunnable examples: examples/.
import { TurboUpload, TESTNET } from "@ardrive/turbo-upload";
// Testnet, so this costs nothing and nothing it writes is permanent.
// Drop `uploadUrl` and `paymentUrl` to talk to production, where uploads are
// permanent, public, and paid for out of the wallet you signed with.
const client = new TurboUpload({
jwk: process.env.ARWEAVE_JWK,
uploadUrl: TESTNET.uploadUrl,
paymentUrl: TESTNET.paymentUrl,
});
const { id, winc } = await client.upload({
data: Buffer.from("hello permanence"),
tags: [{ name: "Content-Type", value: "text/plain" }],
});
// -> https://ar-io.dev/<id> (production reads from https://turbo-gateway.com/<id>)@ardrive/turbo-sdk is the full-featured client, and it is the right choice
for most things. It carries multi-chain signing, wallet connectors, payments
and a CLI, and that costs a dependency tree:
| installed on its own | lockfile entries | on disk |
|---|---|---|
@ardrive/turbo-sdk@1.43.0 |
784 | 895 MB |
@ardrive/turbo-upload@0.3.0 |
1 | 488 KB |
@ardrive/turbo-upload@0.5.0 |
1 | 700 KB |
Measured 2026-09-11 (0.5.0: 2026-10-01) into an empty project with npm install. Re-run it rather
than trusting this table: the numbers move as either tree changes, and the
point is the shape, not the digits.
When you are adding Arweave storage to someone else's server, the size of that tree is what a dependency review weighs, and every package in it is a package somebody has to vouch for. This one does one thing completely, with nothing else in it.
- Signs ANS-104 data items with an Arweave JWK (type 1, RSA-4096 / RSA-PSS-SHA256), a Solana key, or a Solana wallet's
signMessage(type 4, Ed25519) - Runs in Node and in browsers, workers and jsdom, with no polyfills
- Uploads them to a Turbo upload service, in one request or in chunks, from bytes or from a stream
- Prices uploads, reads balances and the free quota, submits SOL top-ups, shares credits, pays with
x-paid-by, and opens Stripe checkout sessions - Verifies data items, including a strict mode most implementations do not have
Ethereum, KYVE or Polygon keys · ArNS · x402 · the CLI, folder uploads, or ArDrive drive abstractions · packing your own bundles · a client that builds and sends the SOL transfer for you.
If you sign with an Arweave or Solana key or wallet and upload bytes, this package does it and brings nothing with it.
import { TurboUpload } from "@ardrive/turbo-upload";
const client = TurboUpload.production({ jwk: process.env.SOLANA_SECRET_KEY, token: "solana" });Takes any form a Solana user actually holds: a base58 secret key as Phantom exports it, the JSON array solana-keygen writes, raw 64 bytes, or a bare 32-byte seed. client.address is the base58 Solana address.
A 64-byte key carries its own public key, and that half is checked rather than trusted. A key whose halves disagree is refused at construction, because signing with one produces items that verify nowhere and you find out after paying.
This is ANS-104 signature type 4, the same type @ardrive/turbo-sdk uses for token: "solana", so the two produce identical ids for identical content. Type 4 signs the hex encoding of the signature data rather than the bytes; the library does that for you, and a test asserts it, because signing the raw bytes yields a valid signature over the wrong message.
A browser holds no keys: a wallet does. Pass anything with a Solana
publicKey and an async signMessage as signer, and the client signs
through it. A Solana wallet adapter has that shape, so you pass the adapter
itself; its other properties are ignored.
import { TurboUpload } from "@ardrive/turbo-upload";
export async function uploadWithWallet(wallet, bytes) {
const client = TurboUpload.testnet({ signer: wallet });
const item = await client.signAsync({ data: bytes, tags: [{ name: "Content-Type", value: "image/png" }] });
return client.uploadSigned(item);
}publicKey can be 32 bytes, a base58 string, or an object
with toBytes() (web3.js). signMessage can answer with the 64 signature
bytes or with { signature }.
The client calls signMessage once per item, with the 96 ASCII bytes of the
lowercase hex of the item's deep hash, as @ardrive/turbo-sdk does. Every
signature is verified before it is used: a wallet that signs something else
would produce an item that verifies nowhere, found out after paying. A
signMessage that throws, an answer that is not 64 bytes, and a signature
that does not verify are all TurboSignerError, and nothing is uploaded.
A Ledger cannot sign data items, whether used directly or as an account
inside a browser wallet: it signs transactions and off-chain messages, not
raw bytes. Expect TurboSignerError from one, with the device's error as
cause.
Use signAsync() with a signer: sign() is synchronous, and a wallet is not.
upload() works with either. In Node, createSolanaSigner(secretKey) wraps a
key you hold in the same shape, so code written against a wallet runs in
tests and scripts unchanged.
The package has a second build, web.js, for browsers, workers and jsdom. It
shares the client with the Node build, hashes in plain JavaScript, returns
Uint8Array where Node returns Buffer, and signs only through a signer:
it refuses a raw key, because a secret key in a page is readable by every
script on it. It needs no Buffer, no process and no polyfill.
You import @ardrive/turbo-upload as usual; the browser export condition
selects the web build. To name a build explicitly, import
@ardrive/turbo-upload/web or @ardrive/turbo-upload/node. Both builds are
CommonJS, which every bundler below consumes.
| bundler or runtime | gets |
|---|---|
webpack 5 (target: "web"), Vite, esbuild platform: "browser", Next.js client code, Cloudflare Workers (workerd) |
web.js |
Jest with jest-environment-jsdom |
web.js |
Node, esbuild platform: "node", Next.js server code |
index.js |
esbuild platform: "neutral", webpack 4 |
a build error: they ignore the browser condition, take the Node build, and cannot resolve node:crypto |
harness/
checks webpack 5, Vite, esbuild and Jest in CI; webpack 4 was checked once by
hand; the Next.js and workerd rows follow from the conditions those tools
document.
Verifying a wallet's signature in the browser. The web build checks each
signature with WebCrypto's Ed25519 (current Chromium, Firefox and Safari have
it), and with the signer's verify(message, signature, publicKey) only where
the runtime has none, as in jsdom or React Native. A wrapper over
@noble/ed25519's verify is enough. With neither, signing throws
TurboSignerError rather than skipping the check.
In the web build, verify() and verifyDataItem() return a promise and check
type 4 items only.
Turbo charges in winc (winston credits). Every call here takes
{ signal, timeoutMs }.
import { TurboUpload } from "@ardrive/turbo-upload";
const client = TurboUpload.testnet({ jwk: process.env.SOLANA_SECRET_KEY, token: "solana" });
const size = client.getDataItemSize({ dataSize: 6_000_000 }); // the signed item, not the payload
const { winc } = await client.getUploadCost(size);
const balance = await client.getBalance(); // { winc, ... }, zeros for a new address
const solBuys = await client.getWincForToken(1_000_000_000); // 1 SOL in lamports
console.log({ winc, balance: balance.winc, perSol: solBuys.winc });The free tier is a quota, and it belongs to the signer. Items up to a
per-item ceiling upload free until the signing address's lifetime allowance
is spent (there is a per-IP one too). Read the numbers from
getInfo().freeTier and getFreeQuota(): they are service policy, and
testnet's differ from production's. A paidBy upload draws no free quota.
Topping up with SOL is two steps, and the first is yours. Send a SOL
transfer to await client.getFundingAddress() with your wallet or RPC
library, wait until it is finalized, then report it:
import { TurboUpload } from "@ardrive/turbo-upload";
export async function creditTopUp(client, signature) {
for (let attempt = 0; attempt < 10; attempt++) {
const result = await client.submitFundTransaction(signature);
if (result.status !== "pending") return result; // "confirmed" or "failed"
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`top-up ${signature} is still pending`);
}Submitting a transaction twice credits it once. To credit another address,
add a memo instruction (MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr) with
the text turboCreditDestinationAddress=<address>. Legacy and version 0
transactions are both credited. Building the transfer is left to you, which
keeps this package free of a Solana library.
Paying for someone else's uploads. shareCredits lets another address
spend up to an amount of yours; that address then uploads with
paidBy: <your address>, and the charge comes out of the approval:
import { TurboUpload } from "@ardrive/turbo-upload";
export async function sponsor(payer, uploader, bytes) {
const size = uploader.getDataItemSize({ data: bytes });
const { winc } = await payer.getUploadCost(size);
await payer.shareCredits({
approvedAddress: uploader.address,
approvedWincAmount: BigInt(winc) * 12n / 10n,
expiresBySeconds: 3600, // the unused part returns when it expires
});
return uploader.upload({ data: bytes, paidBy: payer.address });
}paidBy is one address. The service refuses a list with a 402, so the client
refuses one before anything is signed.
Paying by card. createCheckoutSession({ amount: 1000, currency: "usd" })
opens a Stripe checkout for $10.00 (amount is in cents) and returns its
url. The testnet payment service answers with a Stripe test-mode session.
An item of more than two chunks (over 10 MiB at the default 5 MiB chunk)
goes chunked, over @ardrive/turbo-sdk's routes. Each chunk is its own
request with its own retries, so a dropped connection re-sends one chunk.
paidBy travels with the final request.
import { createReadStream, statSync } from "node:fs";
import { TurboUpload } from "@ardrive/turbo-upload";
const client = TurboUpload.testnet({ jwk: process.env.SOLANA_SECRET_KEY, token: "solana" });
const path = "video.mp4";
const result = await client.uploadStream({
streamFactory: () => createReadStream(path),
size: statSync(path).size,
tags: [{ name: "Content-Type", value: "video/mp4" }],
onProgress: ({ processedBytes, totalBytes }) => console.log(processedBytes, "/", totalBytes),
});
console.log(result.id);uploadStream reads the stream twice, once to sign and once to send, so
streamFactory returns a new stream each call. If the second read differs,
the upload is abandoned unfinalized and nothing is charged. In a browser,
pass () => file.stream().
upload() and uploadSigned() take chunking ("auto", "force",
"disabled"), chunkSize (5 to 500 MiB), chunkConcurrency (default 5) and
onProgress too. If the service refuses a request as too large, the client
reads the limit from the refusal, remembers it, and sends in chunks. A
request's timeout grows with its size: at least timeoutMs, and enough for
128 KiB/s.
A chunked upload the service does not finalize ends in
TurboChunkedUploadError, with uploadStatus set to what the service
reported ("INVALID", for example) and the uploadId to look it up by. To
exercise chunking with a small item, send it with chunking: "force".
| tested | |
|---|---|
| Node | 18.17, 18, 20, 22, 24 (the whole suite, in CI) |
| Browsers | Chromium, Firefox and WebKit through Playwright: the type 4 corpus with no polyfills in CI, and the testnet flow by hand |
| jsdom | jsdom directly, and Jest's jest-environment-jsdom |
| Bundlers | esbuild, webpack 5, Vite (see § In the browser) |
| Keys | Arweave JWK (Node), Solana key (Node), Solana wallet signer (both builds) |
Ethereum, KYVE and Polygon keys are not supported in either build.
The realistic reader already has turbo-sdk wired into a server and wants one upload path out of it. The call maps directly:
// before
import { TurboFactory } from "@ardrive/turbo-sdk";
const turbo = TurboFactory.authenticated({ privateKey: jwk });
const { id } = await turbo.uploadFile({
fileStreamFactory: () => Readable.from(buffer),
fileSizeFactory: () => buffer.length,
dataItemOpts: { tags },
});
// after
import { TurboUpload } from "@ardrive/turbo-upload";
const client = new TurboUpload({ jwk });
const { id } = await client.upload({ data: buffer, tags });What changes beyond the call:
| turbo-sdk | here |
|---|---|
TurboFactory.authenticated({ privateKey }) |
new TurboUpload({ jwk }), and a bad key throws at construction rather than at first upload |
| stream factories | a Buffer, Uint8Array or string to upload(), or uploadStream({ streamFactory, size }) |
walletAdapter |
signer: pass the same adapter |
dataItemOpts.paidBy |
paidBy, one address |
getBalance() returns a signed-in account |
getBalance() returns zeros for an unknown wallet, because the service answers 404 |
errors arrive as fetch failed |
typed errors that name the endpoint, the status and the method |
| any supported token | Arweave JWKs, Solana keys and Solana wallets. Anything else throws at construction |
Keep turbo-sdk for the cases in § Use @ardrive/turbo-sdk instead if you need. Nothing stops both being
installed; they share no state.
| option | default | notes |
|---|---|---|
jwk |
Arweave JWK, an object or a JSON string, or a Solana key with token: "solana". Node build only |
|
signer |
a wallet-style signer, in place of jwk. See § Wallet signers |
|
uploadUrl |
https://upload.ardrive.io |
|
paymentUrl |
https://payment.ardrive.io |
|
timeoutMs |
60000 |
per request, not per call. See § Bounding a call |
retry |
{retries:3, minDelayMs:500, maxDelayMs:8000, retryStatuses:[408,429,500,502,503,504]} |
partial: override one field, keep the rest |
token |
"arweave", or "solana" with signer |
Anything else throws immediately |
fetch |
global fetch |
injectable for tests and proxies |
Everything is validated in the constructor, so a bad key is a startup error that names the problem.
An option this package does not recognise is an error, not something it ignores. That holds for every method that takes an options object, and it exists because the two typos that hide are both expensive:
new TurboUpload({ jwk, uploadServiceUrl: TESTNET.uploadUrl });
// TurboConfigError: new TurboUpload: unknown option `uploadServiceUrl`
// (did you mean `uploadUrl`?). Accepted: jwk, uploadUrl, paymentUrl, ...
client.sign({ data, tag: [{ name: "Chain-Id", value: "1" }] });
// TurboValidationError: sign(): unknown option `tag` (did you mean `tags`?)Silently dropped, the first leaves the client on production, so data meant for a throwaway testnet is written permanently and billed for. The second uploads an item with no tags, which no tag query will find again. Neither shows up in the return value.
const { TurboUpload } = require("@ardrive/turbo-upload");
// Use the helpers rather than spreading TESTNET or PRODUCTION: those records
// also carry `name` and `gatewayUrl`, which are not constructor options.
const client = TurboUpload.testnet({ jwk, timeoutMs: 30_000, retry: { retries: 5 } });timeoutMs applies to each HTTP attempt, and retry runs up to retries more
of them. They multiply. With the defaults, one upload() can take:
(retries + 1) × timeoutMs + backoff
(3 + 1) × 60_000 + ~15s ≈ 4 minutes 7 seconds
There is deliberately no total-deadline option, because the right bound depends on the caller. In a request handler or any path a user is waiting on, set one:
const bounded = AbortSignal.any([shutdownSignal, AbortSignal.timeout(20_000)]);
await client.upload({ data, tags, signal: bounded });Lower timeoutMs alone is not enough: it shortens each attempt, not the call.
Signs and uploads, chunked when the item is large (§ Large items). Returns the service response plus id, owner and
byteCount. The returned id is checked against the id computed locally from
our own signature, and a mismatch throws rather than handing back an id you did
not produce.
Signs without uploading.
Signs through whichever signer the client has. Required with signer.
The byte length of the signed item, without signing it. Price this.
See § Large items.
Uploads bytes already produced by sign().
Use this, not
sign()+upload(). RSA-PSS draws a fresh random salt per signature, so signing the same payload twice yields different bytes and a different id.upload()signs internally; calling it aftersign()signs a second time and the id you printed is not the id that landed.
const item = client.sign({ data, tags });
await recordInMyDatabase(item.idB64Url); // the id, before it exists on-chain
await client.uploadSigned(item); // the same bytes, same idPrice in winston credits for a raw byte count. A signed item is ~1044 bytes
larger than its payload, so price item.binary.length, not your payload length.
A wallet the payment service has never seen answers 404 User Not Found. That
is a zero balance, not an error, and is normalised to zeros.
Service info, including the free-upload threshold, 107,520 bytes when this
was written. Read it from /v1/info rather than from this page: it is service
policy and it changes. Items at or below it upload with no credit
balance at all.
getWincForToken(amount) · getPaymentInfo() · getFundingAddress() ·
getFreeQuota({ address? }) · submitFundTransaction(txId) ·
shareCredits({ approvedAddress, approvedWincAmount, expiresBySeconds? }) ·
createCheckoutSession({ amount, currency?, owner?, ... }). See § Paying for
uploads.
Structural and cryptographic verification. § The PSS salt length, and why it is
478 explains what strictSaltLength catches.
signDataItem · verifyDataItem · createDataItem · parseDataItem ·
getSignatureData · deepHash · serializeTags · deserializeTags ·
signMessage · verifyMessage · idFromSignature · parseJwk ·
ownerFromJwk · addressFromOwner · publicKeyFromOwner ·
createSolanaSigner. The web build exports the format helpers, an async
verifyDataItem, and no RSA or key helpers.
Constants: MAX_TAG_BYTES (4096) · MIN_ITEM_SIZE (1044) ·
PSS_SALT_LENGTH_BYTES (478) · SIGNATURE_TYPE_ARWEAVE (1) · PRODUCTION ·
TESTNET · DEFAULT_TIMEOUT_MS · DEFAULT_RETRY.
Hand-written index.d.ts, so no typescript dependency, no @types/*.
import type { Tag } from "@ardrive/turbo-upload";
// Tag is { name: string; value: string }Tag is exported deliberately: in @ardrive/turbo-sdk you have to go read a
third-party package's .d.ts to learn the shape.
Every error extends TurboError and carries its context.
| class | when | carries |
|---|---|---|
TurboKeyError |
bad/missing/non-RSA-4096 JWK | none |
TurboConfigError |
bad option or unsupported token | none |
TurboValidationError |
bad arguments to a call | none |
TurboNetworkError |
no response at all (DNS, TLS, reset) | endpoint, method, cause |
TurboTimeoutError |
exceeded timeoutMs, or caller aborted |
endpoint, timeoutMs, cause |
TurboHTTPError |
non-2xx | status, endpoint, method, body |
TurboPaymentError |
402, the wallet cannot pay. A TurboHTTPError, so existing catches still work |
status, endpoint, method, body |
TurboVerificationError |
the service returned an id we did not sign | expectedId, receivedId |
TurboSignerError |
a wallet signer threw, answered with something other than 64 bytes, or signed something else. Nothing was uploaded | cause |
TurboChunkedUploadError |
a chunked upload did not finalize (INVALID, or the wait ran out). UNDERFUNDED is a TurboPaymentError |
uploadId, uploadStatus |
TurboHTTPError: POST https://upload.ardrive.io/v1/tx failed: HTTP 402 Payment Required
{"error":"Insufficient balance"}
rather than a bare fetch failed.
The constructor validates eagerly, so a placeholder key will not work. Generate
a throwaway one, and inject fetch:
const { generateKeyPairSync } = require("node:crypto");
const { TurboUpload } = require("@ardrive/turbo-upload");
const jwk = generateKeyPairSync("rsa", { modulusLength: 4096 })
.privateKey.export({ format: "jwk" });
const fetchStub = async (url, init) =>
new Response(JSON.stringify({ id: "…", winc: "0" }), {
status: 200,
headers: { "content-type": "application/json" },
});
const client = new TurboUpload({ jwk, fetch: fetchStub });Two things worth knowing before you write the stub. uploadSigned checks
that the id the service returned matches the one it computed locally, so a stub
returning a fixed id fails with TurboVerificationError rather than the thing
you were testing. Return the id of what was actually POSTed. And signing an
RSA-4096 key takes tens of milliseconds, so generate it once per suite, not per
test.
The error classes take an options object and are declared with constructors, so you can build one directly to test your own handling:
const { TurboPaymentError } = require("@ardrive/turbo-upload");
throw new TurboPaymentError({ status: 402, endpoint: UPLOAD_URL, method: "POST" });Exported as named constants so nobody has to guess a hostname.
Each gatewayUrl is the gateway its own upload service names in /v1/info.
Set it yourself if reads matter to you. Any gateway serving Arweave returns
these items by id, they differ in what they have indexed, and a busy one will
rate limit you: arweave.net answered 429 to ten consecutive reads of items
this package had uploaded moments before.
| upload | payment | gateway | |
|---|---|---|---|
PRODUCTION |
https://upload.ardrive.io |
https://payment.ardrive.io |
https://turbo-gateway.com |
TESTNET |
https://upload.services.ar-io.dev |
https://payment.services.ar-io.dev |
https://ar-io.dev |
The testnet hostnames contain
.services., which is why they are constants rather than prose.upload.ar-io.dev, without.services., resolves and serves an HTML page on every path including/v1/tx, so a wrong hostname gives you a200with an HTML body instead of an obvious failure. Import the constant and the question never arises.
Uploads to PRODUCTION are permanent and cost real money.
ANS-104 says RSA-PSS and stops. This package sets the salt length to 478 bytes explicitly rather than inheriting a default, because that is what the reference implementation produces and getting it wrong does not fail loudly: verification is salt-agnostic, so a 32-byte-salt signature passes round-trip tests, passes cross-verification, and is accepted by the live service. It would fail later, at a stricter verifier.
What that means for you: nothing, unless you sign items yourself elsewhere. If
you do, pass { strictSaltLength: true } to client.verify() to catch it.
The derivation, the empirical recovery of the salt off the wire, and the per-library instructions are in the conformance spec.
The package ships 22 conformance vectors in vectors/vectors.json,
generated from @dha-team/arbundles@1.0.4, the de-facto reference that the
gateways and bundlers actually run. The test suite ships too, so you can
re-prove all of it from your own node_modules:
npm test --prefix node_modules/@ardrive/turbo-uploadThe vectors pin the unsigned item bytes, the deep-hash transcript, every field offset, the serialized tag region and a reference-produced signature per vector. No private key ships: the corpus carries only the public modulus, and the signing tests generate an ephemeral key at runtime.
A second corpus in the repository, conformance/type4-vectors.json, holds 22
complete type 4 (Solana) items generated by @ardrive/turbo-sdk@2.1.0. Ed25519
is deterministic, so it pins every byte, signature and id included; both
builds reproduce it, in Node, in three browsers and in jsdom. It does not
ship, to keep the package small.
Reimplementing the format rather than consuming it? The conformance spec has the byte-level rules, including the two adjacent 32-byte fields with opposite string conventions and the encoder quirks reproduced bug-for-bug.
Node >= 18.17.0 (global fetch, AbortSignal.any), or a browser with
fetch. In a browser, sign with a wallet; a raw key does not belong in a page.
Apache-2.0