AGENTS.md — Migrating from GoPlus to Tok{In}
This file gives AI coding agents (Claude Code, Cursor, Copilot, etc.) the context they need to migrate an existing GoPlus token-security integration over to Dedaub's Tok{In} API with minimal code changes.
Source of truth: https://docs.dedaub.com/docs/token_safety/tokin/
If you are an AI agent helping a developer migrate, read this file end-to-end before editing their code. The sections are ordered so that the first three give you everything needed for a one-line drop-in swap; the rest covers field-level differences, edge cases, and known gaps.
1. TL;DR — the one-line swap
GoPlus's token_security endpoint can be replaced by Tok{In} with three changes:
- Base URL →
https://tokin-api.dedaub.com - Path →
/token/{chain}/{token_address}(chain is a name, not a numeric chain id — see §6) - Auth →
X-API-Key: <YOUR-API-KEY>header (instead of GoPlus's?api_key=query param or app-id/secret signing)
Append ?response_format=goplus to receive field values in GoPlus's schema with "1"/"0" string-boolean encoding. The outer envelope is different (see §2.1) — fields live under features, not at the top level — so a literal drop-in is rarely possible without one small parser change.
# Before (GoPlus)
curl 'https://api.gopluslabs.io/api/v1/token_security/1?contract_addresses=0xABC...'
# After (Tok{In}, GoPlus-compatible response)
curl -H 'X-API-Key: YOUR-API-KEY' \
'https://tokin-api.dedaub.com/token/ethereum/0xABC...?response_format=goplus'
2. Endpoint reference
GET https://tokin-api.dedaub.com/token/{chain}/{token_address}
GET https://tokin-api.dedaub.com/token/{chain}/{token_address}?response_format=goplus
Headers:
X-API-Key: <required>
| Query param | Values | Default | Effect |
|---|---|---|---|
response_format | dedaub | goplus | dedaub | goplus reshapes the body to GoPlus's schema and string-boolean encoding. |
The native dedaub schema is richer (typed booleans, additional fields, structured tax info). Use it for greenfield code; use goplus only when you are migrating existing GoPlus parsers and want to defer schema changes.
2.1 Response envelope (read this before parsing)
Tok{In} always wraps the result in this envelope, regardless of response_format:
{
"chain": "ethereum",
"contract_address": "0xA0b8...eB48",
"features": { /* SafetyFeatures (dedaub) | GoPlusMappedFeatures (goplus) */ },
"error": ""
}
This differs from GoPlus's { "code": 1, "message": "OK", "result": { "0xABC...": { ... } } } shape in two ways that matter to a migrating client:
- Fields are nested under
features, not underresult["<address>"]. Update every accessor:body.is_open_source→body.features.is_open_source. - Errors come via HTTP status code +
errorstring, not an in-bodycode/messagepair. On non-2xx,featuresisnullanderrorcarries the human-readable reason.
Full response schema: see tokin.md.
3. Migration steps for an AI agent
When asked to migrate a codebase from GoPlus to Tok{In}, follow this sequence:
- Locate the GoPlus call sites. Grep for
gopluslabs.io,token_security,goplus, and known SDK imports (e.g.goplus-sdk-py,@goplus/sdk-node). - Identify the auth mechanism in use. GoPlus integrations vary: anonymous,
?api_key=, or app-id/secret signed access tokens. All three collapse to a singleX-API-Keyheader in Tok{In}. - Translate the chain identifier. GoPlus uses numeric chain IDs in the path (
/token_security/1); Tok{In} uses names (/token/ethereum/...). See §6 for the full mapping. If the codebase passes chain IDs around as integers, add a small lookup helper rather than rewriting every call site. - Update the URL builder and add the
X-API-Keyheader. Append?response_format=goplusif the existing parser relies on GoPlus field names and"1"/"0"strings — otherwise migrate to the nativededaubschema in the same change. - Audit field reads. Cross-reference every field the code reads against §4. Pay attention to:
- Computed fields (
transfer_pausable,is_anti_whale, etc.) — these are derived, not 1:1. - Always-
nullfields (§4) — if downstream code branches on them, simplify the branch or wire them to a different signal.
- Computed fields (
- Update error handling. Tok{In} returns standard HTTP codes (§7); GoPlus historically returned
200with acodefield in the body. Replace body-codechecks with HTTP status checks. - Update rate-limit handling. Tok{In} emits standard
Retry-AfterplusX-RateLimit-*andX-Quota-*headers (§8). Wire any retry/backoff logic to those headers. - Run the test suite. If there isn't one for token-security calls, add at least one integration test against a known token (e.g. USDC on Ethereum:
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) before declaring the migration done.
Do not silently delete GoPlus fields the user's code reads — flag them as TODOs (especially the always-null set in §4) so the developer can decide what to do.
4. Field mapping (Tok{In} dedaub → GoPlus)
When response_format=goplus, the server applies these mappings. All booleans become the strings "1" (true) or "0" (false).
Direct mappings
| Tok{In} (Dedaub) | GoPlus | Notes |
|---|---|---|
is_open_source | is_open_source | bool → "1"/"0" |
is_proxy | is_proxy | bool → "1"/"0" |
mint_or_burn_function | is_mintable | bool → "1"/"0" |
owner_address | owner_address | string |
can_selfdestruct | selfdestruct | bool → "1"/"0" |
external_call | external_call | bool → "1"/"0" |
is_in_dex | is_in_dex | bool → "1"/"0" |
receive_tax | buy_tax | decimal as string |
send_tax | sell_tax | decimal as string |
cannot_buy | cannot_buy | bool → "1"/"0" |
tax_can_be_modified | slippage_modifiable | bool → "1"/"0" |
trading_cooldown | trading_cooldown | bool → "1"/"0" |
creator_address | creator_address | string |
creator_percent | creator_percent | decimal as string |
is_launchpad_token | launchpad_token | bool → "1"/"0" |
⚠ Tax direction is inverted. send_tax → sell_tax and receive_tax → buy_tax. Make sure any code computing slippage matches the renamed semantic.
⚠ buy_tax / sell_tax are pool-aware. The mapper first walks features.dex[] and uses the first pool with non-null buy_tax / sell_tax, falling back to the contract-level receive_tax / send_tax only if no pool reports a value. The result is usually closer to real swap behavior than GoPlus's contract-level tax, but it can disagree with the contract-level fields in the same response.
Additional fields populated in goplus mode
Beyond the GoPlus-spec fields, the response also carries:
token_name,token_symbol,total_supply— populated when known.holders[]— array of{ address, balance, percent }. Note thatholder_countitself isnull(see below) but theholders[]array is populated.
Computed mappings (derived, not 1:1)
| GoPlus field | Derived from | Logic |
|---|---|---|
transfer_pausable | cannot_buy, pause_status_can_be_modified | True if either is True |
is_blacklisted | has_blacklist_or_whitelist | same value (temporary) |
is_whitelisted | has_blacklist_or_whitelist | same value (temporary) |
is_anti_whale | has_trading_cap, has_position_cap | True if either is True |
anti_whale_modifiable | trading_cap_can_be_modified, position_cap_can_be_modified | True if either is True |
hidden_owner | allowance_bypass, privileged_spenders | True when the bypass probe named at least one address |
owner_change_balance | owner_can_change_balance | same value |
⚠ Blacklist/whitelist ambiguity. Both flags map from the same source, so the API cannot distinguish blacklist-only, whitelist-only, or both. Code that branches on one vs. the other should be reviewed.
⚠ hidden_owner is tri-state underneath. It maps from the allowance-bypass probe, which simulates transferFrom from candidate callers against a control caller that provably holds no approval. It is "1" only in the address-specific case — a universal bypass (allowance_bypass true, privileged_spenders empty) means the token authorises nobody in particular, which is a broken ERC20 rather than a hidden owner, so it maps to "0". It is null when the probe could not run, which is not the same as "0". Treat null as unknown, never as safe. In dedaub mode read allowance_bypass and privileged_spenders directly — the latter carries the actual addresses that can drain a holder.
Currently always null
These GoPlus fields exist in the response shape but are not yet tracked by Tok{In}. If downstream code reads them, treat the read as a TODO and either remove the dependency or substitute a different signal:
cannot_sell_allcan_take_back_ownershipgas_abusepersonal_slippage_modifiablelp_total_supplyis_airdrop_scamtrust_listholder_countowner_balanceowner_percentis_honeypot(not listed in upstream public docs but always returnsnulltoday)
⚠ is_honeypot is the field GoPlus consumers most often gate trades on. Migration should not treat its absence as "not a honeypot" — derive an equivalent signal from cannot_buy, the per-pool dex[].buy_success/sell_success (in dedaub mode), and tax fields, or block on the field being unavailable.
⚠⚠ buy_success / sell_success are not booleans. They are four-valued: true, false, "pending" (simulation still running) and null (no simulation was attempted for that pool — common for bonding-curve pools). null and "pending" mean unknown and carry no signal. A honeypot check written as if (!pool.sell_success) will flag every healthy token whose pool was not simulated. Test === false explicitly. See tokin.md.
For the full GoPlus mapping (direct, computed, and always-null fields), see tokin.md.
5. Code examples
Python (requests)
import os, requests
API_KEY = os.environ["TOKIN_API_KEY"]
BASE = "https://tokin-api.dedaub.com"
def token_security(chain: str, address: str, *, goplus_compat: bool = True) -> dict:
params = {"response_format": "goplus"} if goplus_compat else None
r = requests.get(
f"{BASE}/token/{chain}/{address}",
params=params,
headers={"X-API-Key": API_KEY},
timeout=30,
)
if r.status_code == 429:
retry_after = int(r.headers.get("Retry-After", "60"))
raise RuntimeError(f"rate limited; retry after {retry_after}s")
r.raise_for_status()
return r.json()
TypeScript (fetch)
const TOKIN_BASE = "https://tokin-api.dedaub.com";
export async function tokenSecurity(
chain: "ethereum" | "binance" | "base" | "arbitrum" | "avalanche" | "robinhood",
address: string,
{ goplusCompat = true } = {},
) {
const url = new URL(`${TOKIN_BASE}/token/${chain}/${address}`);
if (goplusCompat) url.searchParams.set("response_format", "goplus");
const res = await fetch(url, {
headers: { "X-API-Key": process.env.TOKIN_API_KEY! },
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
throw new Error(`rate limited; retry after ${retryAfter}s`);
}
if (!res.ok) throw new Error(`tokin ${res.status}: ${await res.text()}`);
return res.json();
}
Go (net/http)
Idiomatic client with context.Context, wrapped errors, and a typed ErrRateLimited so callers can branch on errors.As and respect Retry-After:
package tokin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
// ErrRateLimited is returned when Tok{In} responds with HTTP 429.
// Callers should sleep for RetryAfter before retrying.
type ErrRateLimited struct{ RetryAfter time.Duration }
func (e *ErrRateLimited) Error() string {
return fmt.Sprintf("tokin rate limited; retry after %s", e.RetryAfter)
}
// Client is a minimal Tok{In} HTTP client. Zero value is not usable; call NewClient.
type Client struct {
APIKey string
BaseURL string
HTTP *http.Client
}
// NewClient builds a Client with sensible defaults. APIKey is read from TOKIN_API_KEY.
func NewClient() *Client {
return &Client{
APIKey: os.Getenv("TOKIN_API_KEY"),
BaseURL: "https://tokin-api.dedaub.com",
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// TokenSecurity fetches token-safety analysis for the given chain and address.
// When goplusCompat is true, the response is reshaped to GoPlus's schema with
// "1"/"0" string booleans.
func (c *Client) TokenSecurity(ctx context.Context, chain, address string, goplusCompat bool) (map[string]any, error) {
u, err := url.Parse(fmt.Sprintf("%s/token/%s/%s", c.BaseURL, chain, address))
if err != nil {
return nil, fmt.Errorf("build url: %w", err)
}
if goplusCompat {
q := u.Query()
q.Set("response_format", "goplus")
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-API-Key", c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
secs, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if secs <= 0 {
secs = 60
}
return nil, &ErrRateLimited{RetryAfter: time.Duration(secs) * time.Second}
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("tokin %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode body: %w", err)
}
return out, nil
}
For typed access, replace map[string]any with a generated struct that mirrors the response shape documented in tokin.md.
Rust (reqwest + tokio)
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
use std::env;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use serde_json::Value;
use thiserror::Error;
const BASE: &str = "https://tokin-api.dedaub.com";
#[derive(Debug, Error)]
pub enum TokinError {
#[error("rate limited; retry after {0:?}")]
RateLimited(Duration),
#[error("tokin {status}: {body}")]
Http { status: u16, body: String },
#[error(transparent)]
Request(#[from] reqwest::Error),
#[error("missing TOKIN_API_KEY")]
MissingKey,
}
/// Fetch token-safety analysis. When `goplus_compat` is true, the response uses
/// GoPlus's schema with `"1"`/`"0"` string booleans.
pub async fn token_security(
chain: &str,
address: &str,
goplus_compat: bool,
) -> Result<Value, TokinError> {
let api_key = env::var("TOKIN_API_KEY").map_err(|_| TokinError::MissingKey)?;
let url = format!("{BASE}/token/{chain}/{address}");
let mut req = Client::new()
.get(&url)
.header("X-API-Key", api_key)
.timeout(Duration::from_secs(30));
if goplus_compat {
req = req.query(&[("response_format", "goplus")]);
}
let resp = req.send().await?;
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
let secs = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
return Err(TokinError::RateLimited(Duration::from_secs(secs)));
}
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(TokinError::Http { status: status.as_u16(), body });
}
Ok(resp.json().await?)
}
For typed access, replace serde_json::Value with a #[derive(Deserialize)] struct mirroring the GoPlus schema (or the native Dedaub shape documented in tokin.md).
Chain-id translation helper
If existing GoPlus code carries numeric chain ids:
const CHAIN_ID_TO_NAME: Record<number, string> = {
1: "ethereum",
56: "binance",
8453: "base",
42161: "arbitrum",
43114: "avalanche",
4663: "robinhood",
};
6. Supported chains
| Name (use in path) | Chain ID |
|---|---|
ethereum | 1 |
binance | 56 |
base | 8453 |
arbitrum | 42161 |
avalanche | 43114 |
robinhood (New) | 4663 |
Any other chain GoPlus supports (Polygon, Optimism, Solana, etc.) is not currently available on Tok{In} — surface this as a blocker rather than silently dropping the call.
⚠ Robinhood Chain (4663) has no GoPlus equivalent. It is a Tok{In}-only chain, so a migration will have no existing call site to translate — but if the codebase enumerates supported chains as a closed list (union type, enum, allowlist), add robinhood to it rather than leaving 4663 to fall through to the unsupported-chain path.
7. HTTP response codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 401 | Missing or invalid API key |
| 422 | Validation error: malformed EVM address or burn/null address (e.g. 0x000…000, 0xdead…dead) |
| 429 | Rate limit or quota exceeded |
| 500 | Internal server error (or upstream analysis failure) |
On 422 and 500 the envelope is still {chain, contract_address, features: null, error: "<reason>"} — the error string is the source of the human-readable reason.
GoPlus's habit of returning 200 with an in-body code field does not apply here — branch on HTTP status instead.
8. Rate limits & quotas
There are three independent enforcement tiers, each emitting its own header set on 429:
| Tier | Headers on 429 | Retry-After? |
|---|---|---|
| Per-minute QPS | Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining | yes |
| Daily quota | Retry-After, X-Quota-Daily-Limit, X-Quota-Daily-Used | yes |
| Monthly quota | X-Quota-Monthly-Limit, X-Quota-Monthly-Used | no |
Two practical implications:
- Retry strategy must branch on which header set is present. Per-minute and daily 429s have a
Retry-Afteryou can sleep on; a monthly-quota 429 means "wait until next billing period" and there is no header telling you when. Treat that case as fatal for the current period. - Headers are emitted only on the failing 429, not on successful responses. Clients cannot proactively monitor remaining budget — only learn about it on rejection. Pace requests defensively rather than racing the budget.
9. Common migration pitfalls
- Reading fields at the wrong nesting level. GoPlus puts fields under
result["<address>"]; Tok{In} puts them underfeatures(see §2.1). This is the most common silently-wrong outcome of an unreviewed AI migration. - Trusting
is_honeypotas a kill switch. It always returnsnulltoday (§4). GoPlus integrations that gate trades onis_honeypot === "1"will treat every token as safe after migration. Replace with a derived signal (cannot_buy, per-pooldex[].buy_success/sell_success, tax checks) or hard-block when the field is unavailable. - Treating a per-pool
nullas a failed trade.dex[].buy_success/sell_successreturnnullwhen a pool was never simulated and"pending"while one is running — neither is a "no". Falsy checks turn both into "cannot sell", which is the inverse of the truth for a perfectly tradeable token (§4). - Treating
"1"/"0"as truthy/falsy in JS. The string"0"is truthy. Use explicit=== "1"checks or convert at the boundary. - Reading always-
nullfields without a guard (see §4). If a GoPlus integration relies onholder_countorowner_percent, they must be sourced elsewhere or the feature degraded. - Reading
hidden_owner === "0"as "no hidden owner". It isnullwhenever the allowance-bypass probe could not run, and"0"for a universal bypass, which is worse rather than better (§4). Anything gating on drain risk should readallowance_bypassandprivileged_spendersindedaubmode. - Forgetting the tax inversion.
send_tax/receive_tax(Tok{In} native) are not the same direction assell_tax/buy_taxuntil the GoPlus mapper renames them. Note also thatbuy_tax/sell_taxmay come from per-pool DEX data (§4) and disagree with the contract-level tax. - Hard-coding chain IDs in the URL. Tok{In} uses chain names; failing to translate produces 422s, not 404s.
- Confusing
response_format=gopluswith the data being GoPlus's data. The schema is GoPlus-shaped, but the underlying analysis is Dedaub's (Gigahorse-based bytecode analysis). Detection coverage and false-positive characteristics will differ.
10. When to drop GoPlus compatibility mode
response_format=goplus is a migration aid, not the long-term recommendation. Once the rest of the integration is stable, prefer the native dedaub schema for:
- typed booleans (no
"1"/"0"parsing), - richer tax structure,
- access to fields not representable in the GoPlus shape (e.g. structured cap modification flags),
- forward compatibility as new detectors land.
The native response shape and field reference are documented in tokin.md.