| Audited | dev.tensor.trade, all published pages, pages fetched 11 October 2026 |
|---|---|
| Date | 11 October 2026 |
| How it ran | local run on our workstation, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 22 findings were first rated Medium or High; 1 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Locations are paths inside the fetched copy of the documentation.
Medium after review (1)
recipes/buy-floor-nft.md:67From the report
tx.txV0
? VersionedTransaction.deserialize(response.txs[0].txV0.data)
: Transaction.from(tx.tx.data)
Same code in recipes/place-a-bid-within-5-of-floor-price.md:65 and reference/tensor-transactions.md:101.
the callback ignores its own element. When an endpoint returns more than one transaction (for example a sell with its mint-proof transaction, reference/selltx-1.md:233), the first transaction is signed and sent repeatedly and the later ones, which carry the actual trade, are never sent.
use tx.txV0.data for the current element.
Review: recipes/buy-floor-nft.md:65-69 maps over response.txs but reads response.txs[0].txV0.data instead of the element's own tx.txV0.data; identical in place-a-bid-within-5-of-floor-price.md:63-67 and in the primary guide reference/tensor-transactions.md:99-103. When a response carries more than one transaction (the bid recipe comment at :70 says "Must send txs serially for a given response!", and selltx-1.md:235 documents an extra mint-proof tx) the first transaction is sent again and the rest are never sent, while the loop still prints "Transaction confirmed" with the same signature. If the first element is legacy and a later one is V0, txs[0].txV0 is null and it throws, which is loud. Stays MEDIUM because it is silent partial execution on the main documented flow across three pages. It is not higher: a typical buy returns one transaction, and the loss is an unfinished trade rather than lost funds.
Low after review (24)
changelog/price-endpoints-change.md:14From the report
For API calls, these changes will take affect **one month after the migration date** too.
Also changelog/%EF%B8%8F-final-migration-dates-%EF%B8%8F.md:54 (":stop_sign: Buy / Sell price arguments need to include creator royalties", under "March 31st", which is not one month after the Feb 17th migration), and reference/quickstart.md:9 ("Breaking changes may occur without warning!").
changing what maxPrice/minPrice mean is a breaking change to a money-moving contract. The two changelog pages give different cutover dates, neither gives a year, and the buy and sell reference pages (reference/buytx-1.md:165, reference/selltx-1.md:165) still do not say whether royalties are included. An integrator cannot tell which meaning applies today. Finding 8 shows the docs' own examples were not migrated.
publish one dated cutover (with the year). Put the royalty-inclusive meaning in the maxPrice/minPrice parameter descriptions. For future breaking changes, announce them, measure who still uses the old meaning, and switch only after that.
Review: Real: :12,14 say "one month after the migration date"; the final-dates page puts it under "March 31st" (...final-migration-dates...md:50,54) against a Feb 17th migration (:31); no year anywhere; buytx-1.md:165 / selltx-1.md:165 do not mention royalties. But the dates differ by about two weeks, the changelog itself records several delays, and the cutover is long past (pages updated Sept 2025). Docs history, not a trap.
reference/buytx-1.md:194From the report
"description": "The address that pays the NFT (if different from buyer)",
...
"description": "The transaction fee payer (if different from buyer)",
The recipes load ~/.config/solana/id.json and run txsToSign.map((tx) => tx.sign([wallet])) (recipes/buy-floor-nft.md:20,71) with no inspection.
the endpoint can build transactions where the payer and the fee payer differ from the buyer. The examples teach developers to sign a server-built transaction with their CLI keypair without checking the fee payer, the signer set, the programs called or the amount transferred. No exploit is shown, but signing blindly is the habit that turns any API-side mistake or compromise into a loss.
in the transaction guide, show a pre-sign check: decode the message, then assert the fee payer, the expected program ids and that the transfer amount is at most maxPrice. Recommend a dedicated low-balance wallet for scripts.
Review: payer (:194) and feePayer (:203-205) exist; recipe loads id.json at buy-floor-nft.md:18-20 and signs at :71.
recipes/websockets-stream-new-transactions-for-a-collection.md:24From the report
socket.addEventListener('open', (event) => {
socket.send(JSON.stringify({
"event": "newTransaction",
the subscription is sent only once, in the open handler. There is no close or error handler, no reconnect and no re-subscribe. After a network blip the script keeps running but receives nothing. Events missed in the gap are lost, because no catch-up route or "since" cursor is documented for the stream.
on close, reconnect with backoff and send the subscription again. Document how to backfill the gap (for example the collection transaction-history endpoint with its cursor).
Review: Subscription sent once in open (:24-33), no close/error/reconnect: true, but this is a 20-line quick-start sample. "No catch-up route is documented" is overstated: getcollectiontxhistory.md (cursor, traits) exists and the report itself names it as the fix; it is just not linked from the recipe.
reference/unsubscribe.md:19From the report
"subscriptionId": "xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx"
none of the subscribe pages (reference/newtransaction.md:36-48, ammorderupdate.md, tcompbidupdate.md) shows an acknowledgement that carries a subscription id. A client cannot release one subscription without closing the whole socket, so subscriptions pile up on long-lived connections. The response sample at line 28 also shows an unresolved template string (${payload.subscriptionId}).
document the subscribe acknowledgement and its id field, and fix the unsubscribe response sample.
Review: Confirmed: grep -ri subscriptionId finds it only in unsubscribe.md:11,19,28, and :28 is an unresolved ${payload.subscriptionId} template. Effect is a doc gap; closing the socket releases everything and a wrong id is a visible error.
recipes/websockets-stream-new-transactions-for-a-collection.md:17From the report
const socket = new WebSocket("wss://api.mainnet.tensordev.io/ws", {
reference/subscription-api-endpoints.md:13 says ping is how to keep the connection alive, but neither the recipe nor reference/websockets.md pings periodically. Neither closes the socket or unsubscribes on exit. The idle timeout is not documented.
add a ping timer that is cleared on close, close the socket on shutdown, and document the idle timeout.
Review: subscription-api-endpoints.md:13 says ping keeps the connection alive; websockets.md:38 sends a single ping.
tx / txV0 is set is stated in prose and contradicted by the schemareference/buytx-1.md:25From the report
"txV0": {
"type": "string",
"format": "byte"
reference/tensor-transactions.md:17-19 says txV0 may be null and then tx must be used. The schema copied into the transaction pages marks txV0 required and non-nullable. It also types both fields as base64 strings, while every example reads tx.txV0.data / tx.tx.data as a byte-array object. A client generated from the schema and one written from the guide cannot both be right.
state the rule (exactly one of the two is set), mark both fields nullable, and pick one wire format (base64 string or byte array) for the schema and all examples.
Review: Confirmed: txV0 is required and not nullable (:24-27,45-47), both fields are format: byte strings, while tensor-transactions.md:15-19 says txV0 may be null and every example reads .data. The schema is the auto-generated type, the guide and recipes are consistent with each other, and a mismatch shows at first parse.
reference/refreshmetadata-1.md:71From the report
"priceUnit": { "$ref": "#/components/schemas/Currency" },
"price": { "anyOf": [ { "$ref": ".../Decimal" }, { "type": "string" }, { "type": "number", "format": "double" } ] }
Currency includes ETH_WEI. A wei amount above 2^53 cannot be held exactly in a double, and the contract does not say which representation goes with which unit. The schema is also marked deprecated (line 402) but is still the live response of this endpoint.
use a single decimal-string type for price, document how it pairs with priceUnit, and replace the deprecated schema.
Review: priceUnit/price at :71-85, ETH_WEI at :62, deprecated flag at :402 belongs to MintWithColl, which is this endpoint's response (:430). Field is lastSale metadata, not an order amount.
maxPrice changed in place, and the buy examples still send the old meaningrecipes/buy-floor-nft.md:54From the report
buyParams.append("maxPrice", response.mints[0].listing.price);
Same code in reference/tensor-transactions.md:88.
changelog/price-endpoints-change.md:16 and :54 say maxPrice must now include creator royalties (1.05 SOL for a 1 SOL listing with 5% royalties). Redefining an existing field instead of adding a new one means old callers keep sending a value that type-checks but means something else. The docs' own buy examples are such callers: for any collection that enforces royalties, the documented buy fails its price check.
compute maxPrice as listing price plus royalties (state the unit: lamports) in both examples. For future semantic changes, add a new parameter, migrate the consumers, then retire the old one.
Review: Real: :54 and tensor-transactions.md:88 send bare listing.price; the changelog (price-endpoints-change.md:16, :54) says to add royalties. It is the strongest of the LOWs: the flagship buy example. But a too-low maxPrice is a cap, so the failure is a rejected buy, not an overpayment, and whether listing.price already includes royalties is not shown in the audited copy (the 200 schema is empty). Stale example, loud failure, no fund risk.
reference/getusertswaporders-1.md:31From the report
"/user/amm_pools": {
"get": {
"operationId": "GetUserTswapOrders",
changelog/%EF%B8%8F-final-migration-dates-%EF%B8%8F.md:41 and :60-61 say TSwap pool creation and deposits are disabled and all TSwap pools are closed. The TSwap pages (getammorders-1.md, tswap*.md, createtswapdepositwithdrawtx-1.md) carry no notice. The TSwap route is named /user/amm_pools while the current AMM route is /user/tamm_pools, which invites mix-ups. The same changelog line also links the "depositing sol" and "depositing NFT" labels to each other's pages, and links a pool-creation page that is not in the reference set.
mark retired pages as removed or deprecated with dates, keep a written list of what was retired and when, and fix the changelog links.
Review: The changelog retires TSwap creation and deposits, not the read endpoints. The "no deprecation notice" point stands as a doc omission and the swapped links on the changelog line are real.
recipes/websockets-stream-new-transactions-for-a-collection.md:40From the report
if (event_stringified !== '' && event_stringified !== null) {
console.log(JSON.parse(event_stringified));
Same snippet in reference/websockets.md:48-49 and reference/newtransaction.md:29-30.
toString() never returns null, so the guard is dead. JSON.parse runs without try/catch, so any non-JSON frame throws inside the listener. No error listener is registered on the ws socket, so a rejected handshake (wrong API key) or a network reset ends the process with an uncaught error instead of a clear message.
wrap the parse in try/catch, ignore or log unexpected frames, and register an error handler.
Review: Dead guard and bare JSON.parse confirmed (:39-41; same in websockets.md:47-49, newtransaction.md:28-30). The server sends JSON (all sample outputs are objects). With the ws package an unhandled error event is thrown with its message ("Unexpected server response: 401"), so "instead of a clear message" is overstated. Sample hardening.
reference/buytx-1.md:170From the report
"format": "float",
"type": "number",
"minimum": 0
maxPrice, minPrice, price, topUp and the TSwap lamports fields use format: float on roughly 30 endpoints (for example listtx-1.md, selltx-1.md:170, collectionbidtx-1.md:172). A 32-bit float cannot hold most lamport values above about 16.7 million exactly, so a client generated from the spec can round a price.
declare lamport amounts as 64-bit integers, or as decimal strings, and state the unit on every amount.
Review: Confirmed, but the extent is 14 pages with format: float (grep -l), not "roughly 30 endpoints". Same pages as listed in the report plus getmintsbycollid, traitbidtx-1, etc.
recipes/buy-floor-nft.md:164From the report
COLLECTION_SLUG=0O0O-1a2b-3c4d
TENSOR_API_KEY=0O0O-1a2b-3c4d
node script.js
assignments on their own lines set shell variables but do not export them, so node script.js sees process.env.TENSOR_API_KEY and the other variables as undefined. This applies to every recipe's "Run Code" block. On top of that:
recipes/bid-wall-discovery.md:10readsCOLLECTION_ID, but its run block (line 199) setsCOLLECTION_SLUG.recipes/calculate-collection-moving-average.md:44readsCOLLECTION_READABLE_SLUG, but its run block (line 227) setsCOLLECTION_SLUG.recipes/place-a-bid-within-5-of-floor-price.md:66usesTransactionwithout importing it (line 10).recipes/calculate-collection-moving-average.md:55callsapi-tradingview.tensordev.io, whilereference/get-ohlc.md:20givesapi-tradingview.mainnet.tensordev.io.
None of these survives a single end-to-end run.
use export VAR=... or prefix the command (VAR=... node script.js), align the variable names, fix the import and the host, and run every recipe end to end in CI before publishing.
Review: Every sub-claim is true and independently checkable, which makes it the best-evidenced of the set. All failures are loud and early (undefined API key, undefined variable, ReferenceError before any send, or a wrong host), so the effect is a failed first run, not a silent wrong result.
recipes/buy-floor-nft.md:71From the report
txsToSign.map((tx) => tx.sign([wallet]));
...
await connection.confirmTransaction(sig, { blockhash: "confirmed" });
Same in recipes/place-a-bid-within-5-of-floor-price.md:69,73.
a legacy Transaction.sign takes signers as separate arguments, so the array form fails on that path. The second argument of confirmTransaction is not a valid commitment. The result of the confirmation is never checked for an error, so a buy or bid that fails on chain is still logged as "Transaction confirmed". A developer can believe a purchase went through when it did not.
sign with tx.sign(wallet) for legacy transactions. Confirm with a valid commitment or a blockhash strategy, and check value.err before printing success.
Review: tx.sign([wallet]) on a legacy Transaction (buy-floor-nft.md:71, place-a-bid...md:69) throws before anything is sent (and in the bid recipe Transaction is not even imported, :10). The report's other point needs correcting: confirmTransaction(sig, { blockhash: "confirmed" }) (:74 / :73) has no signature in the strategy object, so in web3.js 1.x (my knowledge, library not in the audited copy) it should throw after sendTransaction has already landed the transaction, meaning the recipes would log an error rather than "Transaction confirmed". The unchecked-error point does apply to the guide's version (tensor-transactions.md:115-116), but a simulated send makes failure rare. Worst case is a user re-running a bid after a misleading error. Loud and low probability, so LOW; it deserves a fix together with 14.
recipes/buy-floor-nft.md:33From the report
queryParams.append("slug", COLLECTION_SLUG);
/mint/collection requires collId and defines no slug (reference/getmintsbycollid.md:186-191). The same mismatch is in reference/tensor-transactions.md:58, in /tx/collection_bid at recipes/place-a-bid-within-5-of-floor-price.md:52 (requires collId, reference/collectionbidtx-1.md:189-195) and in the newTransaction subscription at recipes/websockets-stream-new-transactions-for-a-collection.md:30 (documented argument collId, reference/subscription-api-endpoints.md:14). In the bid recipe, the floor lookup filters /collections with slugs (line 32), but that endpoint accepts slugDisplays or collIds (reference/getcollections-1.md:330,342). If the unknown filter is ignored, sortBy=statsV2.volume1h:desc&limit=1 returns the busiest collection on the market, and the recipe prices a mainnet bid from that unrelated floor without checking which collection came back.
use the documented parameter names, show how to resolve a slug to a collId, and check that the returned collection is the requested one before using its price.
Review: The spec facts are right: /mint/collection and /tx/collection_bid require collId (getmintsbycollid.md:186-191, collectionbidtx-1.md:189-195), /collections takes slugDisplays/collIds (getcollections-1.md:330,342), the WebSocket argument is collId (subscription-api-endpoints.md:14), and the recipes send slug/slugs. The scary branch (bid priced from the busiest collection's floor) needs two things the audited copy cannot show: that /collections silently ignores slugs, and that collection_bid accepts an undocumented slug. The WebSocket recipe's sample output shows events arriving for a slug subscription, so the server may in fact accept slug. Real inconsistency, and the recipe should check which collection came back, but unverified and conditional.
recipes/bid-wall-discovery.md:22From the report
queryParams.append("limit", 100);
...
for (const bid of response) {
the endpoint is cursor-paginated with a maximum of 100 (reference/getcollectionbids.md:101-120). The recipe never follows the cursor, so the "bid wall" stops at the top 100 bids, and the sample output shows more price levels than 100 bids could produce. The response shape it iterates is not documented.
loop on the cursor until it is exhausted, and document the response shape.
Review: The loop never follows a cursor (:22-31); getcollectionbids.md:95-118 has limit max 100 and cursor. The sample output has 126 price levels, more than 100 bids could produce (counted), so the sample is from another run. Analytics recipe, no funds; inherits 18.
reference/getcollectionbids.md:39From the report
"schema": {}
25 data endpoints declare an empty 200 response schema while 14 accept a cursor ("The cursor returned from the previous response"). The next-cursor field, the end-of-data signal and the item fields are not defined anywhere, so clients cannot paginate reliably (finding 17 is the result). Pagination styles also differ: getmintlist-1.md:120 uses an undocumented after, and getcollections-1.md:378 uses page numbers.
publish response schemas including the cursor and end-of-data signal, and use one pagination style.
Review: Counted: 25 reference pages with "schema": {} and 14 with a cursor parameter. Pagination styles differ (getmintlist-1.md:120 after, getcollections-1.md:378 and searchcollections.md:339 page). A reference gap that shows on the first call, not wrong guidance. Largest real gap in the set, borderline, but missing documentation is LOW under the yardstick.
reference/ammorderupdate.md:30From the report
type: 'ammOrderUpdate',
data: { tx: { tx: [Object], mint: [Object], instr: null } }
ammOrderUpdate, ammOrderUpdateAll, tcompBidUpdate and tcompBidUpdateAll all show the same transaction-shaped payload, with nested objects elided as [Object]. Pool and bid updates are not transaction records, so nobody can write a parser for these events from the docs.
document the real payload of each event with its fields expanded.
Review: Confirmed: ammorderupdate.md:29-30, ammorderupdateall.md, tcompbidupdate.md, tcompbidupdateall.md all show data: { tx: { tx: [Object], mint: [Object], instr: null } }. Copy-paste placeholder; a developer sees the real payload on the first message. Report cites :30, the type: line is :29.
reference/tammcreateordertx.md:213From the report
"name": "startingPrice",
...
"format": "int32",
the unit is lamports (line 208), and int32 tops out at 2,147,483,647 lamports. A pool starting price, delta (line 224) or depositLamports (line 282) above about 2.15 SOL cannot be expressed under the published contract, and a client using 32-bit integers would wrap silently. reference/tammdepositwithdrawsoltx.md:168 and reference/tammeditpooltx.md have the same type.
type lamport amounts as 64-bit integers or decimal strings.
Review: Confirmed: startingPrice :210-213, delta :224, depositLamports :282, and tammdepositwithdrawsoltx.md:165-168, tammeditpooltx.md:169,180. Spec defect, whether the server enforces it is unknown.
reference/depositwithdrawescrowntx-1.md:101From the report
"description": "The amount of SOL to deposit/withdraw",
"in": "query",
"name": "lamports",
the parameter is named lamports but described as SOL, a factor of one billion apart, on an endpoint that moves funds. The same is true in reference/tswapdepositwithdrawsoltx-1.md:163-165, while the AMM twin says lamports. The action descriptions say "deposit" or "withdraw" in lower case (escrow line 83, TSwap and AMM SOL pages line 145), but the enum accepts only DEPOSIT and WITHDRAW (line 20 / 80), so following the description gets a validation error.
state one unit and make the name match it, and make the descriptions quote the enum values exactly.
Review: Confirmed: lamports described "amount of SOL" in depositwithdrawescrowntx-1.md:101-103 and tswapdepositwithdrawsoltx-1.md:163-165, while tammdepositwithdrawsoltx.md:163 says lamports. The unit error that would hurt is reading lamports as SOL in the other direction; a reader following "SOL" and passing 1 deposits 1 lamport, which is harmless. Lower-case "deposit" in the text (:83, :145) vs upper-case enum is a visible 400.
minPrice is described as a maximumreference/selltx-1.md:165From the report
"description": "The maximum price that would be paid for the NFT",
"in": "query",
"name": "minPrice",
the text was copied from the buy endpoint. minPrice is the seller's protection against a bid being lowered before execution. A seller who follows the description and treats it as a cap may set it low and lose that protection.
describe it as the minimum amount the seller will accept, and say whether creator royalties are subtracted, as the changelog requires.
Review: Confirmed: selltx-1.md:165 has the buy text under name: minPrice (:167). The name and the changelog ("subtract the expected creator royalties", price-endpoints-change.md:15) disambiguate. Copy-paste in a generated description.
reference/getbuy.md:161From the report
"url": "https://api.mainnet.tensordev.io/api/v1"
the page title is "Buy Transaction [Devnet Only]", and the same holds for getlist.md, getcancel.md, getedit.md, getapproveseat.md, getinitializemarket.md and getlistings.md. The interactive "Try it!" console and any generated client send these requests to the mainnet host, so a developer testing devnet trading is pointed at mainnet.
give these pages the devnet server URL, or say clearly that they are not available.
Review: Confirmed in all seven pages (getbuy.md:161, getlist.md:171, getcancel.md:161, getedit.md:162, getapproveseat.md:160, getinitializemarket.md:199, getlistings.md:130); every title carries "[Devnet Only]". No devnet host appears anywhere in the audited copy, so the right replacement cannot be stated from here. A Try-it call needs a signed-in key and does not sign, so the effect is an error or a read.
reference/get-ohlc.md:62From the report
"c": {
"type": "array",
"items": { "type": "integer",
the recipe's own output shows fractional SOL prices (close: 49.988...), so a client generated from the schema truncates or rejects them. The status field s has no documented values, so a "no data" reply cannot be told apart from data. resolution says minutes but lists 1D/1W/1M, and countback counts back "from from" while from is documented as unused.
declare number types, list the values of s, and correct the parameter descriptions.
Review: Confirmed: all arrays typed integer with example 1 and s is "string" (placeholder output, :36), resolution "in minutes" lists 1D/1W/1M (:133), countback "starting from from" (:152) while from is "not used" (:144). Fractional prices are real: the recipe output has close: 49.98888... (calculate-collection-moving-average.md:94, a mean of 9 values that cannot all be integers). Read-only chart data.
reference/getmintsbycollid.md:349From the report
"name": "traits",
...
"type": "array",
The description (line 347) says: "Format must match the following JSON string: {"trait_type": ["value1", "value2"]}".
a client generated from the spec sends traits=a&traits=b, not the JSON string the server expects. The same contradiction is in getactivelistingsschema.md:348, getcollectiontxhistory.md:296 and traitbidtx-1.md:231.
declare the parameter as a string containing JSON, or define the array encoding the server accepts.
Review: Confirmed with the same shape in getactivelistingsschema.md:350, getcollectiontxhistory.md:298, traitbidtx-1.md:233 (the bid variant takes {"trait_type": "value"}). The prose and the example object describe the real format; only a generated client would follow the type. A wrong filter returns visibly wrong results or a 400.
docs/max-proof-length-for-tensor-cnfts.md:28From the report
> ❗️ To be safe mint your trees with proofs no longer than 8, worst case 10
the TLDR (lines 17-20) and the table (lines 141-165) cap collection-wide bids at 5 with 4 creators and 8 with 1 creator. A creator who mints a tree at the advertised "worst case 10" gets NFTs that cannot be traded through collection bids, and fixing that means reminting the tree.
make the safe maximum match the table (5 for the worst case), or say which instruction the 10 applies to.
Review: Confirmed inconsistency: :28 says "no longer than 8, worst case 10", the TLDR (:17-19) and table (:147-155) cap collection bids at 5 (4 creators) and 8 (1 creator). It is design advice for one niche (compressed NFTs) and bites only collection-wide bids; list/buy limits are 14 to 17. The right numbers are in the TLDR directly above. Reminting cost makes it worth a fix, not MEDIUM.
Excluded on review (1)
Findings the review showed to be wrong or a repeat of another finding. They are not counted above.
- 13. The sign-and-send snippet is copied into three pages and the copies have drifted (duplicate): A maintenance remark on 14 and 15; no separate reader-facing defect.