OpenChart
Claude·Codex와 함께하는 무료 데스크톱 차트 도구. 알림 설정·차트 움직임 분석·데이터 제공자 선택
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
NinjaPay lets Nigerian teams and communities pay people in USDC & Injective, straight from their own wallets.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗[!IMPORTANT] NinjaPay is a testnet prototype. It is not audited and must not be used with mainnet funds. The naira off-ramp and bill payments are not live: no payout partner is connected and NinjaPay never takes custody of funds. See Feature status and Known issues before building on it.
NinjaPay is a Next.js dApp for moving value on Injective without handing over your keys. One interface covers:
Every transfer is built in the browser and signed by the user's own wallet (Keplr, Leap, or an EVM wallet through RainbowKit, Phantom included). NinjaPay does not run a backend that holds keys or funds. The only server-side state is claim-pool metadata stored in Supabase.
The longer-term goal is real-world utility for users in Nigeria: cashing out to NGN bank accounts and paying utility bills. Both are deliberately disabled until a licensed payout partner is integrated. The interface says so wherever they appear.
| Feature | Route | Status | What actually happens today |
|---|---|---|---|
| Send INJ | /send | Working on testnet | Native value transfer from the connected EVM wallet through wagmi useSendTransaction. The recipient can be typed as inj1…, 0x… or a .inj name; an inj1… address is converted to its 0x… form. |
| Send USDC | /send | Built; not yet sent on testnet | An ERC-20 transfer on Circle's USDC contract from the connected EVM wallet, like any token in MetaMask. The gas limit is the wallet's estimate plus 30% for Circle's compliance hook. USDC is a MultiVM token, so the bank balance moves with it; Keplr and Leap aren't needed to send. |
| Send USDC without INJ | /send | Built; off until set up | For an account with USDC but not enough INJ for the fee. The wallet signs the exact transfer as EIP-3009 typed data (no transaction, no fee), and /api/relay/usdc checks it and has it submitted, with NinjaPay paying the INJ gas. The USDC goes straight from the sender to the recipient; NinjaPay never holds it. Off unless the server has USDC_RELAY_FACILITATOR_URL or USDC_RELAYER_PRIVATE_KEY; while off, Send looks as before. Tested on a local chain with a stand-in token, not yet with Circle's USDC on testnet. |
| Wallet setup | /setup | Working | Adds or switches the wallet to Injective's EVM network and adds USDC to its token list in one click each, with the values for adding them by hand. Links to the INJ and Circle USDC testnet faucets (on mainnet, Injective's page on getting INJ) and to Keplr and Leap. Linked from the landing page and from Send when the account has no INJ. |
| Receive | /receive | Working | Shows the wallet's account as inj1… and 0x… with a QR code for each and a network warning. Ask for a set amount makes a link and QR that open /send with the address, token and amount filled in; the page reports the payment as received once the account's balance of that token on Injective has gone up by at least that amount. Person-to-person only. |
| Payroll | /payroll | Built; not yet sent on testnet | Rows take an address or a .inj name, shown with the address it points to on review. Pays everyone in one : one signature, one fee, all or nothing. The connected EVM wallet signs it as EIP-712 typed data, or Keplr/Leap signs it natively once connected. Up to 50 recipients per run. Every row is checked against the token's rules before signing, since one blocked recipient fails the batch. Paid runs are saved on the device and each is checked against its transaction before it shows as paid; a run can be used again or saved as CSV. An account can give another a payroll budget (an authz send approval with a cap, an end date and an optional list of accounts), and the other account can then pay runs from it. |
lib/paystack.ts and lib/vtpass.ts contain integration code for Paystack and VTPass, but no page imports them today.
NinjaPay talks to Injective over two rails. Which rail a transfer uses depends on the token, not on how the recipient's address is written: inj1… and 0x… are the same account.
flowchart LR
subgraph Browser["Browser (Next.js client)"]
UI["App routes<br/>/send /payroll /claims"]
RK["RainbowKit + wagmi + viem"]
CW["Keplr / Leap<br/>(window.keplr, window.leap)"]
SDK["@injectivelabs/sdk-ts<br/>MsgSend, bank gRPC"]
end
UI -- "INJ" --> RK
UI -- "USDC" --> CW
CW --> SDK
RK -- "eth_sendTransaction" --> EVM["Injective EVM testnet RPC"]
SDK -- "signed Cosmos tx" --> COSMOS["Injective Cosmos testnet<br/>injective-888"]
SDK -- "balances (gRPC)" --> COSMOS
UI -- "claim pools, tx history" --> SB[("Supabase<br/>Postgres")]
UI -- "INJ / USDC price (Pyth, REST)" --> COSMOS
MsgMultiSend, MsgSend, authz and fee-grant messages) built with @injectivelabs/sdk-ts, for Payroll, claim links and Approvals. Keplr or Leap sign them in SIGN_MODE_DIRECT; an EVM wallet signs them as EIP-712 typed data (SIGN_MODE_EIP712_V2). Balances come from the bank module over gRPC (lib/injective/bank.ts).lib/prices.ts), are shown as indicative, and are hidden once they are more than 10 minutes old. Injective has no naira price, so the app shows no naira rate; that will come only from a licensed partner's quote./) is a Server Component and does not load the wallet stack. Web3Providers is mounted only in app/(dashboard)/layout.tsx and app/claim/layout.tsx.| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack), React 19, TypeScript 5.9 |
| Styling | Tailwind CSS v4 (@tailwindcss/postcss) with CSS variable tokens in app/globals.css |
| Motion | motion (motion/react), with prefers-reduced-motion respected |
| Fonts | Geist and Geist Mono via next/font |
| EVM wallets | RainbowKit 2, wagmi 2, viem 2 |
| Cosmos / Injective | @injectivelabs/sdk-ts, networks, ts-types, utils (all pinned to 1.20.52, upgraded together) |
| Data | Supabase (@supabase/supabase-js), TanStack Query |
| State | React state, Zustand available |
| Icons | lucide-react |
All network settings live in lib/injective/network.ts. Set NEXT_PUBLIC_INJECTIVE_NETWORK=mainnet to target mainnet; anything else (including unset) means testnet.
| Setting | Testnet | Mainnet |
|---|---|---|
| Cosmos chain ID | injective-888 | injective-1 |
| EVM chain ID (wagmi, RainbowKit, "add network") | 1439 (0x59f) | 1776 (0x6f0) |
| EVM JSON-RPC | https://k8s.testnet.json-rpc.injective.network/ | https://sentry.evm-rpc.injective.network/ |
| EVM explorer (0x transaction hashes) | https://testnet.blockscout.injective.network | https://blockscout.injective.network |
| Cosmos explorer (Cosmos transaction hashes) | https://testnet.explorer.injective.network | https://injscan.com |
explorerTxUrl in lib/injective/network.ts picks the explorer from the hash itself: 0x plus 64 hex digits goes to Blockscout (/tx/<hash>), anything else to InjScan (/transaction/<hash>). The InjScan path follows the old explorer's URLs; it could not be checked from the build environment.
The EVM chain ID and the Cosmos chain ID name the same network, so a 0x… address and its inj1… form are one account with one balance (Injective docs, converting addresses). NinjaPay accepts either format anywhere it asks for an address, stores the inj1… form, and shows both on the dashboard (lib/injective/address.ts). Earlier builds pointed wallets at chain 2424, which is inEVM; Injective's EVM cheat sheet says not to use inEVM because it is deprecated.
Token settings live in lib/injective/tokens.ts.
| Token | Testnet | Mainnet | Decimals |
|---|---|---|---|
| INJ | inj | inj | 18 |
| USDC (Circle, native) | erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d | erc20:0xa00C59fF5a080D2b954d0c75e46E22a0c371235a | 6 |
USDC is Circle's native USDC on Injective (Injective docs). It follows the MultiVM Token Standard: the ERC-20 contract (the address after erc20:) and the bank denom are one token with one balance, so a MetaMask user's USDC shows up without Keplr. Denoms and logos come from Injective's token lists.
Two details to keep in mind:
peggy0x2791…4174. It is not Circle's native USDC and is labelled USDC.e (legacy) in history. Reclaiming a claim pool returns every token it holds, including that one./api/tokens reads on the server (the testnet list is over 20 MB). A listed token that calls itself INJ or USDC on another denom, such as Ethereum's bridged INJ (peggy0xe28b…), keeps its denom beside the name and is never priced as INJ. A token not on the list shows as a short denom and raw amount, marked unverified. Which assets can be cashed out to naira is for the licensed partner to decide, not this list.Amounts. Cosmos amounts are integer strings in base units: 1 INJ = 10^18 inj and 1 USDC = 10^6 base units. Convert exactly once, at the edge where the message is built. Most of the current payment bugs come from converting twice.
app/
layout.tsx Root layout: fonts, metadata (no wallet providers)
page.tsx Landing page (Server Component)
globals.css Design tokens (Injective palette), base layer, utilities
(dashboard)/ Wallet-enabled app, wrapped in Web3Providers
layout.tsx Navigation, footer, Web3Providers
send/ payroll/ claims/ bills/ beneficiaries/ transactions/ analytics/
page.tsx Dashboard home (shares the "/" path with app/page.tsx)
claim/
layout.tsx Web3Providers for public claim links
[claimId]/page.tsx Public claim redemption page
receipt/[hash]/page.tsx Shareable receipt read from the chain (no wallet needed)
api/evm-rpc/route.ts Optional EVM RPC proxy that keeps a provider key on the server
api/health/route.ts What an uptime monitor polls: chain, EVM, database and relay
api/claims/route.ts Claim-link bookings written by the server, signed by the link's key
api/cron/settle-claims/ Daily job that settles claims nobody came back to (vercel.json)
api/relay/usdc/route.ts Sends USDC for people without INJ, NinjaPay paying the fee (off until set up)
api/tokens/route.ts Injective's verified tokens, trimmed for the browser
components/
landing/ Client leaves for the landing page (Reveal, Steps, Faq, HeroArt)
Navigation.tsx App nav with RainbowKit ConnectButton
Web3Providers.tsx wagmi config, RainbowKit theme, QueryClient
OfframpUnavailable.tsx Honest "not live" off-ramp placeholder
SwapQuote.tsx INJ → USDC quote from the Swap precompile
StatusChip.tsx, TxStatus.tsx Transfer state chip and status line with explorer link
PayrollRuns.tsx Past payroll runs, each checked against its transaction
PayrollBudgetForm.tsx Give another account a payroll budget
ChainHealthNotice.tsx "Sending is paused" and scheduled-upgrade banners
QrCode.tsx Dark-on-white QR code as one SVG path
CopyButton.tsx Copy-to-clipboard button
LivePayments.tsx Live updates and "Payment received" notices on the dashboard
hooks/
useWallet.ts Thin wrapper over wagmi useAccount
useCosmosTransaction.ts Keplr/Leap connection and sendToken
useEvmSigner.ts The connected EVM wallet as a signer for Cosmos messages (EIP-712)
useUsdcRelay.ts Send USDC without INJ: sign the transfer, then hand it to the relay
useBalance.ts INJ/USDC balances from the bank module
useChainHealth.ts Chain id and block freshness for the rail a page sends on
useActivity.ts On-chain history for the connected accounts, paged and merged
useConnectedAccounts.ts The connected inj1 accounts, once each
useTokenList.ts Injective's verified tokens, by denom
usePrices.ts Indicative INJ and USDC prices, refreshed each minute
useRecipients.ts Recipient fields: address or .inj name, resolved
useSwapQuote.ts INJ → USDC quote, slippage floor, fee and oracle check
useTransferChecks.ts Pre-send checks for one transfer
usePayrollChecks.ts Pre-send checks for every row of a payroll run
lib/
injective/
network.ts Network, chain ids, endpoints, explorers, faucets
tokens.ts INJ and native USDC: denoms, decimals, contracts
token-list.ts Names for other denoms from Injective's verified token list
address.ts inj1… and 0x… as one account
names.ts .inj names through the Injective Name Service
swap.ts Swap precompile: INJ/USDC route, allowlist, quote maths
transfer-checks.ts Circuit breaker, token rules and new-address checks
fees.ts INJ network fee maths and checks
gas-price.ts The gas price Injective requires right now, for Cosmos transactions
usdc-authorization.ts USDC transfers signed as EIP-3009 typed data, and their checks
usdc-relay.ts Server side of USDC without INJ: checks, submit, confirm from the receipt
health.ts Chain id, block freshness and upgrade-plan checks
transfer-errors.ts USDC compliance-hook errors in plain words
bank.ts Balance queries, MsgMultiSend builder
cosmos-transactions.ts Cosmos signing: Keplr/Leap direct, EVM wallets over EIP-712; sendToken
phantom.ts Phantom: how it's spotted, its testnet hint, Solana bridge links
claim-escrow.ts Claim-link escrow: plan, fund, pay out, sweep
claim-grant.ts Claim links paid from the creator's wallet: approve, pay out, cancel
link-broadcast.ts Claim payouts: take turns in a crowd, read back a payout by its hash
payroll-budget.ts Payroll budgets: give, read, check and pay a run from one
payroll-reconcile.ts Whether a saved payroll run matches its transaction
activity.ts Bank-transfer history from Injective's indexer, merged across sources
evm-activity.ts EVM wallet transfers (INJ value, ERC-20) from Blockscout
receipt.ts One transaction's transfers, fee and status, from REST or EVM RPC
analytics.ts Totals over whole-day windows: per token, USD, counts, chart
live.ts Live signals: EVM Transfer-log subscription, indexer portfolio stream
grants.ts Authz grants and fee allowances: read, describe, revoke
cctp.ts Circle CCTP V2 contracts and burn call for a future payout partner (not wired)
constants.ts Re-exports network settings, env-backed config
broadcast.ts, evm-config.ts, types.ts
money.ts Exact amount <-> base-unit conversion
rate-limit.ts In-memory request limits for server routes
circuit-breaker.ts Skips a failing provider for a while instead of waiting on it
claim-recovery.ts Settles claims left pending, by asking the chain
claim-proof.ts The link's key signs each claim booking; the server checks it
claims-client.ts Claim bookings from the browser: the server first, the public key until it is set up
shared-rate-limit.ts Rate limits counted in the database, shared by every server instance
prices.ts INJ and USDC prices from Injective's Pyth oracle
payment-request.ts Payment-request links to /send
statement.ts CSV activity statement and payroll runs, made in the browser
payroll-runs.ts Paid payroll runs, saved in this browser
supabase.ts Claim pools and claims: reads, and writes through either key
supabase-server.ts The service-role client, for server routes only
paystack.ts, vtpass.ts Payout and bill integrations (not wired to any page)
public/
favicon.png The app icon at 512px (social cards, README)
brand/ The in-app logo (ninja-mark.svg), hero mark (ninja-hero.svg) and the footer's engraved savanna (savanna.webp)
scripts/savanna.js Draws the footer's savanna as SVG
sql/ Phase 2 database changes: shared rate limits, and the claim-write lock
tests/ Unit tests (npm test)
e2e/ Money paths on a local Injective chain (npm run test:e2e)
fixtures/ A stand-in for Circle's USDC with EIP-3009 (source and compiled)
.github/workflows/e2e.yml Runs the end-to-end tests on demand
git clone https://github.com/ALIPHATICHYD/NINJA-PAY.git
cd NINJA-PAY
npm install
cp .env.example .env.local # then fill in your Supabase URL and anon key
npm run dev
Open http://localhost:3000.
In development, every route is compiled the first time you open it. The first load of a wallet-enabled route can take several seconds because it compiles the Injective SDK, viem, and WalletConnect; later loads are fast. To judge real performance, run a production build:
npm run build && npm start
Variables prefixed NEXT_PUBLIC_ are bundled into client JavaScript and visible to anyone. Only put publishable values in them. INJECTIVE_EVM_RPC_URL, USDC_RELAY_FACILITATOR_URL, USDC_RELAYER_PRIVATE_KEY, SUPABASE_SERVICE_ROLE_KEY and CRON_SECRET are server-only. See Security model.
| Variable | Required | Used by | Notes |
|---|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Yes, for claims and analytics | lib/supabase.ts | Supabase → Project Settings → API. |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes, for claims and analytics | lib/supabase.ts | Publishable anon key. Protect tables with RLS. |
SUPABASE_SERVICE_ROLE_KEY | No (server-only) | lib/supabase-server.ts | Supabase → Project Settings → API keys → service_role (secret). With it, the server writes claim links (/api/claims) and counts the relay's limits in the database, so the database can refuse writes from browsers. It skips Row Level Security, so set it only in the host's environment settings, never with a NEXT_PUBLIC_ prefix. See Locking claim writes. |
CRON_SECRET | No (server-only) | app/api/cron/settle-claims/route.ts | Any long random string. Vercel sends it to the daily claim-settling job, which refuses calls without it. |
ALERT_WEBHOOK_URL | No (server-only) | lib/alerts.ts | A Slack or Discord incoming webhook. Server errors, page errors, a low relayer balance and a failed daily job are posted to it as well as logged. See docs/operations.md. |
NEXT_PUBLIC_INJECTIVE_NETWORK | No | lib/injective/network.ts | mainnet to target mainnet. Defaults to testnet. |
NEXT_PUBLIC_INJECTIVE_GRPC / NEXT_PUBLIC_INJECTIVE_REST / NEXT_PUBLIC_INJECTIVE_INDEXER / NEXT_PUBLIC_INJECTIVE_EXPLORER | No | lib/injective/network.ts | Chain gRPC-web, LCD, indexer and indexer explorer URLs from a premium provider. Default to Injective's shared public endpoints, which its docs don't recommend for production traffic. |
Example .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
NEXT_PUBLIC_WALLETCONNECT_ID=your-walletconnect-project-id
ENV_SETUP_GUIDE.md has step-by-step instructions for obtaining each key.
Run the following in the Supabase SQL editor. The columns match what lib/supabase.ts reads and writes.
create extension if not exists "uuid-ossp";
create table if not exists claim_pools (
id uuid primary key default uuid_generate_v4(),
creator_address text not null,
name text not null,
total_amount text not null, -- human-readable amount, not base units
claim_type text not null, -- split type: equal | percentage | custom
shares jsonb not null default '[]'::jsonb, -- [{ "address": "", "amount": "1.5" }]
claimed_by jsonb not null default '[]'::jsonb, -- ["inj1…", …]
link_code text not null unique,
created_at timestamptz not null default now()
);
create index if not exists claim_pools_creator_idx on claim_pools (creator_address);
create table if not exists transactions (
id uuid primary key default uuid_generate_v4(),
user_address text not null,
type text not null check (type in ('send', 'bills', 'claim', 'payroll')),
status text not null check (status in ('pending', 'confirmed', 'failed')),
amount text not null,
recipient text,
tx_hash text,
created_at timestamptz not null default now()
);
create index if not exists transactions_user_created_idx
on transactions (user_address, created_at desc);
-- Claim-link escrow. Only the escrow's public address is stored; its key never reaches the server.
alter table claim_pools add column if not exists token text check (token in ('INJ', 'USDC'));
alter table claim_pools add column if not exists escrow_address text;
-- One row per claimed share. The unique constraints are the lock that stops
-- a share being paid twice or one address claiming twice.
create table if not exists claims (
id uuid primary key default uuid_generate_v4(),
pool_id uuid not null references claim_pools (id) on delete cascade,
share_index int not null,
claimer_address text not null,
status text not null default 'pending' check (status in ('pending', 'paid')),
tx_hash text,
created_at timestamptz not null default now(),
constraint claims_one_per_share unique (pool_id, share_index),
constraint claims_one_per_claimer unique (pool_id, claimer_address)
);
If you created the tables from an earlier version of this README, run the alter table and create table claims statements above as a migration. Older databases may also be missing transactions.user_address, which makes create index ... transactions_user_created_idx fail with column "user_address" does not exist. Add it first with alter table transactions add column if not exists user_address text;. The claims code looks for the constraint names claims_one_per_share and claims_one_per_claimer, so keep them as written.
The app talks to Supabase from the browser with the anon key, so enable RLS on both tables. The app has no wallet-signature auth yet, which means the database cannot verify who is making a request. Until auth exists, treat these tables as untrusted, public data:
alter table claim_pools enable row level security;
alter table transactions enable row level security;
alter table claims enable row level security;
-- Prototype policies: anyone may read and insert. Tighten before any real use.
create policy "read claim pools" on claim_pools for select using (true);
create policy "create claim pools" on claim_pools for insert with check (true);
create policy "read claims" on claims for select using (true);
create policy "reserve claims" on claims for insert with check (true);
create policy "mark claims paid" on claims for update using (true);
create policy "release claims" on claims for delete using (status = 'pending');
create policy "read transactions" on transactions for select using (true);
create policy "insert transactions" on transactions for insert with check (true);
These policies let any client insert or update claim rows. The funds themselves are protected by the link's key and the chain's limits on it, not by the database: a forged claims row cannot move tokens. What a hostile client can do is reserve shares it never pays out, or mark rows paid. That is acceptable on testnet, and the next section closes it.
With SUPABASE_SERVICE_ROLE_KEY set, the browser no longer writes claim rows. It sends each write to /api/claims, signed by the claim link's key (lib/claim-proof.ts), and the server checks the signature belongs to that link before writing with the service role. Settling a claim needs no signature, because the server only does what the chain says about its payout. Without the key, /api/claims answers 503 and the browser writes with the anon key as before, so the key can be added at any time.
Once the server writes claim links, the database can refuse writes from browsers. In order:
sql/phase2-rate-limits.sql in the Supabase SQL editor. It adds a rate_limits table and a hit_rate_limit function that only the service role can use, and changes nothing existing.SUPABASE_SERVICE_ROLE_KEY (and CRON_SECRET) in Vercel and redeploy./api/health says "the server writes claim links", then create and claim one link.sql/phase2-lock-claim-writes.sql. It drops the public insert, update and delete policies on claim_pools, claims and transactions, and keeps the public reads the claim page needs. The file ends with the statements that undo it.Both files were checked on Postgres 16 with Supabase's roles: before the lock the anon role can insert and delete claim rows; after it, its inserts are refused, its updates and deletes change nothing, it can still read, and the service role can still write.
/send)The user chooses INJ or USDC and enters a recipient. It can be written as inj1… or 0x…. parseAccountAddress in lib/injective/address.ts checks it (bech32 checksum for inj1…, EIP-55 checksum for mixed-case 0x…), shows the other form under the field, and blocks sending to your own wallet. A payment-request link from Receive (/send?to=…&token=…&amount=…) fills in all three and reminds the payer to check the address with the person who sent it.
It can also be a .inj name. lib/injective/names.ts asks the Injective Name Service's registry contract for the name's resolver and the resolver for its address, and the page shows the full address under the field; that address is what gets paid. Names must be at least 3 lowercase letters, digits or hyphens, which keeps out lookalike Unicode names. A typed address shows its primary .inj name only if that name resolves back to the same address, as the INS docs advise.
Before Send is enabled, lib/injective/transfer-checks.ts asks the chain three things. Has Injective's circuit breaker switched off this kind of transaction? (Injective's chain at v1.20.3 doesn't include the circuit module, so for now this finds nothing; see Known issues.) Do the token's permission rules pause sending or receiving, or leave either account without the role for it? Has the recipient ever been used? The first two block the send and explain why in plain words, as the chain's or the issuer's rule; NinjaPay doesn't screen transfers and never suggests a way around a restriction. An unused recipient is a warning only.
The amount is converted to exact base units as it is typed. The page shows the network fee in INJ (fees are always paid in INJ, even for USDC; lib/injective/fees.ts) and disables Send when the sending account can't cover the amount plus the fee. Max leaves room for the fee when sending INJ.
INJ: the page calls wagmi sendTransaction({ to, value }) with the recipient's 0x… form and the exact base units. The connected EVM wallet signs, and useWaitForTransactionReceipt tracks confirmation.
Injective's EVM charges the whole gas limit at the transaction's fee cap (maxFeePerGas) and refunds no unused gas. So both INJ and USDC transfers ask the wallet for the price the page quoted, with no priority fee, and USDC transfers also for the quoted gas limit. The fee charged is then the fee shown. Left to itself, a wallet sets the cap above the price (viem uses 1.2x) and the user pays the difference.
Payroll, claim links and revokes are Cosmos transactions. The connected EVM wallet signs them, or Keplr or Leap once connected. sendToken in lib/injective/cosmos-transactions.ts converts the human-readable amount to base units once with toChainAmount, which is string-based and rejects too many decimal places. It builds a MsgSend and hands it to signAndBroadcast, which:
lib/injective/gas-price.ts);createTransaction, a timeout height and a fee at that gas price;SIGN_MODE_DIRECT, or the EVM wallet to sign the transaction as EIP-712 typed data (eth_signTypedData_v4, switching the wallet to Injective's EVM network first if needed; Phantom signs under Ethereum's chain id instead, and is asked to switch to Ethereum or Sepolia). The typed data is Injective's v2 layout: the messages and the fee, account, sequence and timeout as two JSON strings, which injective-core renders the same way to check the signature. An EVM wallet doesn't report its public key, so the key is recovered from the signature, checked against the connected account, and sent with the account's first Cosmos transaction;/payroll)The payroll screen has three steps: name the run, add recipients (inj1…, 0x… or a .inj name; a row that repeats an account already listed is flagged), then review and dispatch. Dispatch sends one MsgMultiSend, built by buildPayrollMultiSend in lib/injective/bank.ts: one input carrying the total and one output per recipient. That is one signature and one fee, and the bank module applies it atomically: either every recipient is paid or none is. A run pays up to 50 recipients, NinjaPay's own cap. Because one blocked recipient fails the whole batch, the review step first checks the circuit breaker for MsgMultiSend, the token's permission rules for the sender and every row, and flags rows that have never been used (hooks/usePayrollChecks.ts). The batch is simulated for gas before the wallet opens. The employer's own wallet signs; NinjaPay never holds payroll funds.
Past runs. A run that confirms is saved in this browser only (lib/payroll-runs.ts), like beneficiaries: the database has no auth yet, and who was paid how much is personal data under Nigeria's NDPA. A saved run is a claim about the chain, not a record of payment. Each one is shown as Paid on chain only when its transaction pays every row, exactly, from the account the run says, and nothing else (lib/injective/payroll-reconcile.ts). Otherwise it shows as failed, not found, pending, or not matching, with the rows that differ. Use again fills a new run from it, and CSV saves it with the result of that check.
Payroll budgets. The account that holds the payroll funds (the owner) can let another account (an operator, such as a payroll officer) pay payroll from it: Let someone else run payroll signs one authz SendAuthorization with a total, an end date of 7, 30 or 90 days, and optionally only the accounts of a saved run (lib/injective/payroll-budget.ts). The operator then sees Pay from with the budget, and a run goes out as one MsgExec carrying one MsgSend per row from the owner's account. The operator signs and pays the fee; the owner's account pays the total. The chain takes each payment off the budget and refuses the whole transaction if any one goes over what's left, pays an account not on the list, or comes after the end date, so either everyone is paid or nobody is. The page checks all three before the wallet opens. The owner sees the budget on Approvals and can revoke it there at any time. NinjaPay holds nothing and can't use a budget. Giving one again to the same account replaces the old one.
A treasury that needs several people to approve each run (M-of-N) isn't built: it needs Cosmos's group module, and Injective's chain at v1.20.3 doesn't enable it (its queries answer Not Implemented).
/claims → /claim/[claimId])A claim link carries a one-time key in its #fragment. There are two kinds. By default the funds stay in the creator's wallet and the key may only spend what the creator approved for it (lib/injective/claim-grant.ts). The other kind moves the funds to a one-time escrow account that the key controls (lib/injective/claim-escrow.ts).
Funds stay in the creator's wallet
SendAuthorization lets it send at most the total of that token from the creator's account. A fee allowance lets it spend up to 0.000096 INJ per share of the creator's INJ on network fees. The allowance also creates the key's account on chain, which is what lets it sign while holding nothing.https://…/claim/<code>#k=<key>&kind=grant.MsgExec carrying a MsgSend from the creator to the claimer, signed by the key with the creator as fee granter. Before signing, the page reads what the approval still allows and the creator's balances, and stops with a plain message if the link has ended or the wallet no longer holds enough, so nothing is charged. The chain itself refuses anything over the total or after the expiry./claims revokes both approvals. It needs only the creator's wallet, not the link, so it works from any browser. The approvals also appear on /approvals under the link's name.Nothing leaves the creator's wallet until someone claims. The trade-off is that a claim fails if the creator has spent the funds in the meantime; the escrow kind avoids that.
Escrow: creating a pool
BigInt. Any indivisible remainder goes to the first shares, one base unit each, so the shares always sum to exactly the total.crypto.getRandomValues, and saves it to localStorage before any funds move, so the creator can always reclaim.MsgSend to the escrow address. It carries the total, plus an INJ reserve for fees: enough for 600,000 gas (0.000096 INJ) for each share plus one final sweep. For USDC pools, the message carries two coins, sorted by denom as the chain requires.https://…/claim/<code>#k=<escrow key>. Browsers never send the #fragment to a server.Escrow: claiming
escrow_address.0x address is converted to the matching inj1 address).claims row: through /api/claims with a request signed by the link's key when the server is set up, or with the anon key before then. The unique constraints on (pool_id, share_index) and (pool_id, claimer_address) make that insert the lock.MsgSend of that share to the claimer. Gas is sized by simulation (1.3x, capped at 600,000, which the fee reserve covers). The payout's hash is saved on the row before it is sent; if it can't be saved, nothing is sent. If USDC's compliance hook runs out of gas, the payout is retried once with twice the gas. The row is then marked paid. If the chain refused the payout or ran it and it failed, the reservation is deleted so someone else can claim that share.Escrow: reclaiming. In /claims, the creator's browser can sweep everything left in the escrow (unclaimed shares plus unused fee reserve) back to the funding address. After a sweep, remaining claimers will see that the pool is out of funds.
Many claimers at once. Everyone who claims from a link pays out from the same account (the escrow, or the link's key), and the chain takes that account's transactions one sequence number at a time. When another claimer's payout got there first, the chain answers with the sequence it expects next, and the payout is signed again with it after a short random pause. A claimer who hasn't had a turn after 60 seconds is told the link is busy and that nothing was sent (lib/injective/link-broadcast.ts). On the local test chain, 25 people claiming one link at the same moment were all paid within about 16 seconds.
Claims left unfinished. A claim can stop between reserving a share and being marked paid: the tab closes, the connection drops, or the chain doesn't answer. When the chain doesn't answer, the page looks the payout up by its saved hash. If the chain has it, the claim is paid; if the chain ran it and it failed, the share is freed. If it still can't tell, the claimer is told the payout may have landed, the share stays reserved for them, and they get a link to look it up. Nothing runs on a schedule to tidy these up. Instead they are settled when they next matter (lib/claim-recovery.ts):
A reservation is freed only when its payout was never sent, ran and failed, or is unknown to a chain that is producing blocks, 10 minutes after it was made, long after it could still be included (it is valid for 120 blocks). If a freed share's payout did land late anyway, the chain still can't pay out more than the link holds or was approved for, so one claimer would go without; nobody is paid twice.
Trust model. NinjaPay never holds the key or the funds. The link is a bearer secret: anyone who has it can claim, and a malicious holder could take everything the key can reach directly: the escrow's balance, or, for a link paid from the creator's wallet, up to the approved total before it expires or is cancelled. Share links privately. "One claim per address" is enforced by the database, not the chain, and it stops honest double-claims, not a determined attacker with many addresses. An escrow contract could enforce one claim per address on chain, but a contract NinjaPay deploys that holds users' funds raises a custody question for counsel first (see Roadmap).
Pools created before this design have no escrow and are shown as unfunded. They cannot be claimed.
NEXT_PUBLIC_* variables. Anything with that prefix is readable in the browser bundle. Paystack secret keys, VTPass credentials, and any escrow private key must live in server-only code (route handlers or server actions) and non-public environment variables.USDC_RELAYER_PRIVATE_KEY). It only pays gas: the signed transfers it submits move USDC from the sender to the recipient and can't be redirected or reused. Keep only a little INJ in it. The relay logs no addresses, signatures or the key.SUPABASE_SERVICE_ROLE_KEY lets the server write claim links and count rate limits. It can read and write every table, so it lives only in the host's settings, and only route handlers import lib/supabase-server.ts. The server writes a claim row only for a request signed by that link's key, or when the chain settles it.next.config.ts).vercel.json), next to the Supabase project in Ireland and closer to Nigeria than Vercel's default of Washington, D.C. Every claim makes several database calls, so this saves a transatlantic round trip on each./api/claims takes up to 200 requests a minute from one network, enough for about 50 claims, counted in the database when the server key is set. It is generous because many mobile users in Nigeria share one carrier IP address.constants.ts hardcodes Network.Testnet. Moving to mainnet must be a deliberate change, reviewed alongside the fixes below.To report a vulnerability, open a private security advisory on the GitHub repository rather than a public issue.
Rolling back, backups, alerts, spend and the live database are in docs/operations.md.
NinjaPay is a website, a few server routes on Vercel, a Supabase database for claim links, and Injective. Money only ever moves on Injective, signed by the user's wallet or by a claim link's key, so when a part fails the aim is that a payment either happens once or clearly doesn't, and that the person is told which.
GET /api/health answers 200 when everything below is working and 503 when anything isn't, with one line per part. Point an uptime monitor (Better Stack or UptimeRobot, for example) at it and alert on a 503. It returns no addresses, keys or settings, and reuses its answer for 15 seconds, so polling it every minute costs the services behind it almost nothing.
{ "status": "degraded", "checkedAt": "2026-10-13T17:20:00.000Z",
"checks": { "chain": { "ok": true, "detail": "producing blocks" },
"evm": { "ok": true, "detail": "producing blocks" },
"database": { "ok": false, "detail": "not answering" },
"relay": { "ok": true, "detail": "off" } } }
| Part | What people see | What NinjaPay does | What to do |
|---|---|---|---|
| Injective stops producing blocks, or the endpoint is down | Send, Payroll, Claims and the claim page say sending is paused | Checks the chain id and the latest block every 30 seconds and blocks sending until both look right. A claim payout that can't be confirmed keeps its share reserved | Check Injective's status channels. If only the endpoint is down, point NEXT_PUBLIC_INJECTIVE_* at another provider and redeploy |
| The premium EVM RPC fails | Nothing | The proxy sends reads to Injective's public RPC and skips the provider for 30 seconds before trying it again | Check the provider's status, or unset INJECTIVE_EVM_RPC_URL |
| Supabase is down, or the project was paused | Claim links can't be created or opened. Send, Payroll and history work, as they don't use it | Nothing is paid without a reservation, so no claim can be paid twice | Restore or unpause the project in Supabase. Claims left pending settle themselves afterwards |
| The relayer wallet runs low on INJ | Nothing at first: /api/health reports it while 20 transfers' worth is left. Once it is empty, Send without INJ says NinjaPay can't pay network fees right now, and no USDC moves | The chain refuses a transfer the relayer can't pay gas for, so the sender's signed transfer is never used | Send a little INJ to the relayer wallet. Its key is in the host's settings, not in the app |
| Many people claim one link at once | Claims take a few seconds longer. Past what one link can pay in 60 seconds, some people are told to try again in a minute, with nothing sent | Payouts take turns on the link's account (see Claim links) | Nothing. For a very large drop, split it across several links |
| A claimer closes the tab mid-claim | When they come back, the link shows their claim as paid, under way, or open to claim again | Settles the claim against the chain when it is next needed | Nothing |
| A Vercel instance restarts | Nothing | With SUPABASE_SERVICE_ROLE_KEY set, nothing: the relay's limits are counted in the database. Without it, they start counting again (known issue 12) | Nothing |
Wallets, pages and the chain hold everything that matters, so there is nothing for NinjaPay to restore after an outage beyond the database, so keep backups of it on Supabase's side. Claim links' keys were never in it.
On the local test chain one claim link paid about 1.6 claims a second, all from one account taking turns. That puts the limit for people claiming the same link within the same minute at roughly 90; this is an estimate, and blocks on testnet and mainnet will make it differ. Different links pay from different accounts, so they don't wait on each other. The relay sends one transfer at a time per server instance, waiting only for its nonce and send, not for the block. Until SUPABASE_SERVICE_ROLE_KEY is set, two parts stay as they were: the relay's limits are kept per instance (known issue 12), and browsers write claim rows with the public database key. See Locking claim writes.
These are verified against the current code. They are the priority list before any real-value use.
| # | Severity | Issue | Where |
|---|---|---|---|
| 1 | Low | A claim left pending is settled only when it next matters: when its claimer opens the link again, or when someone finds the link full. Nothing runs on a schedule, so until then the creator's list may count that share as unclaimed. See Claims left unfinished. | lib/claim-recovery.ts |
| 2 | Medium | Claim links are bearer secrets and one-claim-per-address is database-enforced, not on-chain. See the trust model under Claim links. | lib/injective/claim-escrow.ts |
| 3 | Medium | VTPass credentials are read from NEXT_PUBLIC_* variables and would be exposed in the browser if enabled. | lib/vtpass.ts |
| 4 | Low | The link's key, for re-copying the link and reclaiming an escrow, is kept in the creator's localStorage. Clearing site data, or switching browsers, loses it there; the full link is the backup. A link paid from the creator's wallet can still be cancelled without it. | lib/injective/claim-escrow.ts |
| 5 | Low | app/page.tsx and app/(dashboard)/page.tsx both resolve to /. Next.js builds, but only one page is reachable. | app/ |
| 6 | Low | amount columns and share amounts are stored as human-readable strings. Floating-point math on them (parseFloat, / count) can produce rounding drift. Use bignumber.js, which is already a dependency. | app/(dashboard)/claims/page.tsx |
| 7 | Low | Ledger accounts in Keplr/Leap are rejected with a clear error that suggests connecting the Ledger through an EVM wallet instead. A Ledger in MetaMask is asked for the same EIP-712 signature as any MetaMask account, but this hasn't been tried with a real Ledger, and Injective's Ledger guide signs the older EIP-712 layout (SIGN_MODE_LEGACY_AMINO_JSON), so a Ledger may need that instead. |
Fixed:
SUPABASE_SERVICE_ROLE_KEY set, the server writes claim links instead, only for requests signed by that link's key, and sql/phase2-lock-claim-writes.sql then makes the database refuse writes from browsers. In a browser run against the local chain with the lock in place, a forged insert was refused, a forged delete changed nothing, and claims still paid out./api/cron/settle-claims) settles claim reservations nobody came back to.account sequence mismatch, because every payout comes from the link's one account. On the local chain 7 of 8 failed. Payouts now take turns, and 8 (and, in a heavier run, 25) all get their exact share, for both kinds of link.pending locked their share for good. They are now settled against the chain when the link is next used./api/health reports whether the chain, the EVM RPC, the database and the relay are working, for an uptime monitor.eth_gasPrice. A claim that fails only because fees are briefly higher than the link set aside says the network is busy, and a payout whose price rose before the chain checked it is retried once at the new price. The end-to-end chain now runs at twice the default minimum with the base fee on, where the old code failed 11 of 19 tests.The UI uses Injective's published brand palette, defined as CSS variables in app/globals.css and exposed to Tailwind through @theme inline.
| Token | Hex | Use |
|---|---|---|
| Ocean | #4d3dff | The single accent: primary buttons, focus rings, active states |
| Snow | #eeefff | Primary text on dark surfaces, text on Ocean |
| Sky 400 / 500 | #aac2ff / #87a7f3 | Secondary text, accent-coloured text (AA-safe on Midnight) |
| Midnight 900 / 800 / 700 | #0b182b / #182e4b / #193d6d | Backgrounds, surfaces, tinted bands |
| Coral | #ffa36e | "Not live" and warning states only |
| Lime | #ceffc8 | Success states only |
Conventions:
prefers-reduced-motion.app/icon.svg (the same icon is in app/favicon.ico, app/apple-icon.png and public/favicon.png). In-app logos use public/brand/ninja-mark.svg. Give a changed image a new file name, because next/image caches optimized images for four hours by URL. The hero shows a lit version, public/brand/ninja-hero.svg.node scripts/savanna.js > savanna.svg redraws it; export that at 2x as WebP to replace public/brand/savanna.webp.| Command | What it does |
|---|---|
npm run dev | Start the dev server (Turbopack) on port 3000 |
npm run build | Production build with type checking |
npm start | Serve the production build |
npm run lint | ESLint (eslint-config-next) |
npm test | Unit tests (Vitest) |
npm run test:e2e | End-to-end tests on a local Injective chain; see Testing |
lib/supabase.test.ts contains manual connectivity helpers and isn't part of either suite.
npm test runs the unit tests in tests/. They mock the chain. On GitHub, every pull request and every push to main runs the type check, the unit tests and a production build (.github/workflows/ci.yml). Lint runs too but doesn't fail the check yet, because of the 33 problems already on main.
npm run test:e2e runs the paths that move money against a real Injective chain on your machine. tests/e2e/local-chain.ts starts a single-validator chain from a fresh genesis and deletes it afterwards. It uses testnet's chain ids (injective-888, EVM 1439), so NinjaPay's testnet settings apply unchanged with the endpoints pointed at 127.0.0.1. Its minimum gas price is twice injectived's default, with the adaptive base fee on, so a path that paid a fixed gas price would be turned away. Signing goes through NinjaPay's own code: a stand-in for Keplr signs SIGN_MODE_DIRECT with a throwaway key, a stand-in for MetaMask signs EIP-712 typed data with viem, and EVM transfers are sent with viem the way wagmi sends them. Keys are made fresh for each run and never written to the repo.
What it covers:
MsgMultiSend pays every recipient exactly, with one signature and one fee, and the receipt reads it back. A run the account can't pay the fee for stops before the wallet opens. A run larger than the balance stops before signing, and nobody is paid.inj1 balance, and the fee charged is exactly the fee Send quotes. A wallet's higher fee cap is charged in full, and the receipt shows it.CROWD=25 npm run test:e2e -- surge tries more). The hash saved before a payout is sent is the one that lands, and a hash the chain has never seen is only called lost once it could no longer be included.tests/e2e/fixtures, same EIP-712 domain and EIP-3009 methods), an account with no INJ signs a transfer the way MetaMask signs typed data, the relayer wallet submits it, the USDC moves exactly, the relayer pays the whole fee and the payer none, and the receipt shows both. The same signature is refused the second time; a payer with no USDC, one with INJ, a signature for another contract and a transfer the token reverts are refused before any gas is spent.A local chain doesn't have these, so they aren't covered: Circle's USDC contract and its compliance hook, .inj names (the INS contracts), swap quotes (no INJ/USDC market), and history and live updates (no indexer, explorer or Blockscout).
You need an injectived binary built from Injective's chain source. Go fetches the version its go.mod asks for.
git clone --depth 1 --branch v1.20.3 https://github.com/InjectiveFoundation/injective-core
(cd injective-core && go build -tags netgo -o ~/bin/injectived ./cmd/injectived)
INJECTIVED=~/bin/injectived npm run test:e2e
The build takes a few minutes and the suite about 30 seconds. Injective's install guide also lists a Docker image, but at a tag (v1.14.1) from before the EVM. On GitHub, Actions → End-to-end tests → Run workflow builds the chain and runs the suite.
MsgMultiSend per run, saved runs checked against the chain, and payroll budgets for an operator are in place. Still to do: CSV import. A treasury that needs several approvers per run waits on Injective enabling the group module; NinjaPay would never be one of its members, since that would give it a say over the funds. Whether payroll needs tax (PAYE) or CBN reporting is a question for an accountant and counsel, so NinjaPay doesn't present runs as tax records.transactions, and read status back from the chain by transaction hash.lib/injective/cctp.ts already holds Circle's CCTP V2 contracts for Injective (domain 29) and builds the depositForBurn call; no page uses it, and the app would describe it as sending USDC to the partner, never as a NinjaPay payout.git checkout -b fix/cosmos-broadcaster.npm run lint and npm run build before opening a pull request..env.local or any key material.Issues from the Known issues table are good first contributions.
Injective publishes tooling for coding agents. It's for building NinjaPay; none of it ships in the app.
Docs MCP server at https://docs.injective.network/mcp: read-only search over Injective's docs, with no key. .mcp.json adds it for Claude Code in this repository, which asks you to approve it the first time. For Cursor or Claude Desktop, run it through npx mcp-remote https://docs.injective.network/mcp.
Agent skills from InjectiveLabs/agent-skills. These three match this codebase:
npx skills add InjectiveLabs/agent-skills --skill injective-usdc-integration
npx skills add InjectiveLabs/agent-skills --skill injective-frontend-wallet
npx skills add InjectiveLabs/agent-skills --skill injective-evm-developer
Check what they say against the docs before it goes into code, as for any Injective detail here. For example, the USDC skill writes the erc20: denom in lowercase while the token list checksums it, which is why tokens.ts compares denoms with sameDenom.
Injective MCP server (InjectiveLabs/mcp-server): useful for poking at testnet from your own machine, with INJECTIVE_NETWORK=testnet and a throwaway wallet. It creates and holds private keys and signs real transactions. Never run it for users, never connect it to a NinjaPay deployment, and never import a wallet that holds real funds. Wallet passwords go through its tool calls and can end up in MCP client logs.
This repository does not include a license file yet. Until one is added, all rights are reserved by the authors.
MsgMultiSend| Claim links: create | /claims | Working on testnet (INJ verified) | By default the funds stay in the creator's wallet (an EVM wallet such as MetaMask, or Keplr/Leap): the creator approves the link to send at most the total, and to pay each claim's fee, until it expires (1, 7 or 30 days), and can cancel it at any time. Or the creator funds a one-time escrow account and can reclaim leftovers. Either way the link's key lives only in its #fragment and the creator's browser. |
| Claim links: redeem | /claim/[claimId] | Working on testnet (INJ verified) | Reserves a share atomically in Supabase, then pays it to the claimer's Keplr/Leap address (or the inj1 form of their EVM address), from the creator's wallet or from the escrow. Claimers need no INJ. |
| Transactions | /transactions | Working | One list, newest first, for your Keplr/Leap account and your EVM wallet: bank transfers from Injective's indexer, and INJ and ERC-20 transfers sent from EVM wallets (such as USDC from MetaMask) from Blockscout. Load more pages further back, and Download CSV saves the listed transfers as a statement made on the device. Each row links to its receipt. Claim activity is labelled by matching escrow addresses to claim pools. Other tokens are named from Injective's verified token list; a token not on it shows as a short denom marked unverified. |
| Beneficiaries | /beneficiaries | Working (this browser) | Saved to localStorage, deliberately not to Supabase, which has no auth yet. Accepts inj1…, 0x… or a .inj name, stores the inj1… form, and spots the same account saved twice in different formats. A beneficiary saved by name keeps the name and is paid at the saved address; the list warns when the name now points somewhere else. Send prefills /send with the address. |
| Live updates | every dashboard page | Working | While the dashboard is open, balances and history update when a payment arrives, and a Payment received notice links to its receipt. The signals come from an ERC-20 Transfer log subscription on Injective's EVM WebSocket and the indexer's account portfolio stream, both opened from the browser. Amounts always come from re-reading the chain, never from the signal. INJ sent from an EVM wallet emits no Transfer log, so its notice depends on the portfolio stream reporting the balance change, which Injective's docs don't confirm; balances still refresh every 30 seconds either way. NinjaPay runs no server-side watcher, since that would link wallets to people (NDPA). |
| Approvals | /approvals | Working | Lists the authz grants (another account may act for yours) and fee allowances (another account may pay its network fees from your INJ) that each connected account has given or received, read from the chain, each in plain words with its cap and expiry. Grants with no cap over funds and grants with no expiry are flagged. Revoking is one signature (MsgRevoke or MsgRevokeAllowance) from the wallet holding the account: the EVM wallet over EIP-712, or Keplr/Leap. ERC-20 spending allowances aren't listed. |
| Receipts | /receipt/[hash] | Working | A shareable receipt for any transaction hash, Cosmos or EVM: amount, sender and recipient, time, block, network fee, memo and status, read from the chain each time it opens. Linked from Transactions and from Send and Payroll once a transfer settles. NinjaPay keeps no copy; the link holds only the hash. |
| Analytics | /analytics | Working | Totals over whole days (7, 30 or 90, back to local midnight): exact amounts sent and received per token, the same in USD at today's Injective oracle price (indicative), distinct transactions, counterparties, a daily or weekly chart and a breakdown by type. It reads older history until every source reaches the window's first day, up to 10 pages per source at a time, and says how far back the totals go when it stops short. Tokens with no current price are named and left out of USD totals. Failed transfers, moves between your own connected accounts and reclaimed claim funds aren't counted. |
| Off-ramp to NGN | /send (Off-Ramp tab) | Not live | Placeholder only (components/OfframpUnavailable.tsx). No naira rate is quoted and no bank details are collected. |
| INJ → USDC quote | /send (Off-Ramp tab) | Waiting on Injective's swap allowlist | Asks Injective's Swap precompile what the INJ/USDC spot market would give for an amount of INJ, with a 0.5% slippage floor, the market's taker fee rate, the swap's network fee and a check against the Pyth price. Quote only: no swap button and no naira amount. It shows a quote only once Injective adds the market to its swap allowlist, which NinjaPay can't do; until then it says so. |
| Bill payments (airtime, data, electricity, cable) | /bills | Not live | Form is disabled; no payment is taken and nothing is sent to a provider. |
| Wallet connection | all app routes | Working | RainbowKit (EVM wallets) for everything: Send as EVM transactions, and Payroll, claim links and revokes as Cosmos messages signed over EIP-712. Keplr/Leap is optional and signs those natively when connected. Keplr/Leap is only asked to connect when the user clicks Connect Keplr or Leap; later visits reconnect quietly. |
| Phantom | all app routes | Built; not yet tried with Phantom | Phantom is in the connect list. It can't join Injective's EVM network (Phantom has no custom networks), but its Ethereum account is the same Injective account, so it signs Injective's Cosmos messages as EIP-712 typed data under Ethereum's chain id, which the chain also accepts: 1 on mainnet, Sepolia's 11155111 on testnet (lib/injective/phantom.ts). Send pays INJ and USDC as a MsgSend, and Payroll, claim links and revokes work as for MetaMask. On testnet Phantom needs its Testnet Mode for Sepolia. Setup shows Phantom its own steps and links Injective's guides for bringing INJ and USDC from Solana. Things bound to Injective's EVM chain id stay with MetaMask and other EVM wallets: EVM transactions and sending USDC without INJ. Tested on a local chain with a Sepolia-signed transfer, not yet with the Phantom app. |
NEXT_PUBLIC_INJECTIVE_EVM_WS | No | lib/injective/live.ts | EVM WebSocket (wss://) for live payment updates. Used in the browser, so it must be keyless. Defaults to Injective's public WebSocket endpoint from the EVM network information page. |
NEXT_PUBLIC_INJECTIVE_EVM_RPC | No | components/Web3Providers.tsx, lib/injective/health.ts | EVM JSON-RPC for reads. A keyless provider URL, or /api/evm-rpc to use the server proxy below. The public RPC stays as a fallback. |
INJECTIVE_EVM_RPC_URL | No (server-only) | app/api/evm-rpc/route.ts | A premium EVM RPC URL with its API key. The proxy forwards only read methods and eth_sendRawTransaction, falls back to the public RPC (skipping the provider for 30 seconds after it fails), and allows 600 requests a minute per network. It never logs requests; a failed upstream is logged as which endpoint failed and how, and the first failover in an hour alerts the operator. |
USDC_RELAY_FACILITATOR_URL | No (server-only) | lib/injective/usdc-relay.ts | Turns on sending USDC without INJ through an x402 facilitator that settles Injective USDC (POST /verify and /settle, x402 v2). It submits the transfer and pays the gas, so NinjaPay holds no key. Used when both relay settings are set. No public facilitator lists Injective yet (checked 2026-10-08). |
USDC_RELAYER_PRIVATE_KEY | No (server-only) | lib/injective/usdc-relay.ts | Turns on sending USDC without INJ from a wallet NinjaPay controls, which pays the gas. 0x plus 64 hex digits. Use a new wallet that holds only a little INJ; it never receives anyone's USDC. Set it in the host's environment settings, never in a file in the repo, and never with a NEXT_PUBLIC_ prefix. |
NEXT_PUBLIC_WALLETCONNECT_ID | Required for any deployment | components/Web3Providers.tsx | NinjaPay's own project id from WalletConnect Cloud, with the site's domains on its allowlist. Mobile and QR-code wallets connect through it. A shared fallback id is hardcoded only so local development works. |
NEXT_PUBLIC_BACKEND_URL | No | lib/injective/constants.ts | Defaults to http://localhost:3001. No backend ships with this repo. |
NEXT_PUBLIC_PAYSTACK_KEY | No | lib/paystack.ts | Not used by any page. Use a public key only (pk_test_…). |
NEXT_PUBLIC_VTPASS_USERNAME / NEXT_PUBLIC_VTPASS_PASSWORD | No | lib/vtpass.ts | Not used by any page. These are credentials and must not ship to the browser; move them server-side before enabling bills. |
USDC: the page calls wagmi writeContract for an ERC-20 transfer(to, amount) on Circle's USDC contract, from the same wallet and with the same receipt tracking. USDC is a MultiVM token, so the transfer moves the bank balance Keplr and Leap show too. Every USDC transfer runs Circle's compliance hook, so the gas limit is the wallet's estimate plus 30%. Estimating already runs the hook, so a restricted transfer is reported as the issuer's rule before the wallet opens.
USDC without INJ: when the account holds the USDC but not the INJ for the fee, and the server has the relay set up (GET /api/relay/usdc), Send offers Send without INJ instead of blocking. The wallet signs an EIP-3009 TransferWithAuthorization for exactly this amount and recipient, valid once for 10 minutes, as typed data in Circle's USDC domain (name USDC, version 2; lib/injective/usdc-authorization.ts). Signing isn't a transaction and costs nothing. /api/relay/usdc then checks the signature, the time window, that the authorization is unused, that the sender holds the USDC and really can't pay the fee in INJ, that a dry run passes (which also runs Circle's compliance hook) and that the fee is at most 0.002 INJ, all before any gas is spent. It has transferWithAuthorization submitted by the facilitator or NinjaPay's relayer wallet, at the current gas price with the estimate plus 30% as the limit, and reports the transfer as sent only once the receipt shows the USDC Transfer from the sender to the recipient. If it can't tell whether a transfer went through, it says so rather than inviting a second payment. The receipt page shows who paid the fee. Each sender gets 10 relays an hour and everyone together 100, counted per server instance.
Phantom: Phantom can't send on Injective's EVM, so for Phantom both INJ and USDC go as a Cosmos MsgSend from the same account, through sendToken and the steps below. Phantom signs it as EIP-712 typed data under Ethereum's chain id (ETHEREUM_SIGNING_CHAIN_ID: 1 on mainnet, 11155111 on testnet); injective-core accepts those ids beside Injective's own (injective-chain/app/ante/eip712.go at v1.20.3). The page quotes the Cosmos fee and shows the transfer as confirmed once it is in a block. Sending USDC without INJ isn't offered to Phantom, because the EIP-3009 authorization names Injective's EVM chain id.
| A bad deploy |
| Errors on pages that worked before |
| Nothing |
| Roll back to the previous deployment in Vercel (Instant Rollback), then fix forward |
lib/injective/cosmos-transactions.ts |
| 8 | Low | The circuit-breaker check never finds anything. Injective's docs describe the circuit module, but the chain at v1.20.3 doesn't include it and answers the query with 501 Not Implemented, which the check treats as no finding. The end-to-end tests fail if a later chain version starts serving it. | lib/injective/transfer-checks.ts |
| 9 | Low | A payroll paid from a budget shows on its receipt and under Past runs, but Transactions and Analytics may not list it. History comes from Injective's indexer, and how the indexer reports payments inside an authz MsgExec hasn't been checked, since a local chain has no indexer. | lib/injective/activity.ts |
| 10 | Low | Past runs are kept in this browser's localStorage. Another browser, or cleared site data, shows none; the payments themselves are on chain. | lib/payroll-runs.ts |
| 11 | Medium | There is no Terms of Service or Privacy Policy yet. So nothing says who may use NinjaPay (no minimum age) or what personal data it keeps: creator and claimer wallet addresses, claim-link names and transaction hashes in Supabase. Both need a lawyer, not code. The connect dialog used to say "you agree to our Terms of Service" and the app footer linked Terms, Privacy and Support to nowhere; those were removed until real pages exist. | components/WalletButton.tsx, app/(dashboard)/layout.tsx |
| 12 | Medium | Sending USDC without INJ limits each sender to 10 relays an hour and everyone to 100, but the counts live in each server instance's memory, so on Vercel the real limit is higher and resets on restart. Someone with many funded accounts can spend the relayer wallet's INJ up to those limits (two relayed USDC transfers on Injective mainnet cost their relayer 0.00008 and 0.0003 INJ). With SUPABASE_SERVICE_ROLE_KEY and sql/phase2-rate-limits.sql in place, the counts are kept in the database and hold across instances; until then they are per instance. Its other open question is Injective's own facilitator: whether it is live, and on what terms, isn't confirmed. | app/api/relay/usdc/route.ts |
| 13 | Low | Phantom support hasn't been tried with the Phantom app. The chain accepts the signatures (shown on a local chain for Sepolia's chain id, and a mainnet id is refused on testnet), but whether Phantom signs this typed data while on Ethereum or Sepolia, and switches between them when asked, needs a real wallet. Phantom also doesn't list Injective balances itself. | hooks/useEvmSigner.ts |
| 14 | Medium | npm audit lists 13 high-severity advisories in the packages the app ships, all inside the wallet libraries: WalletConnect and Reown AppKit, and the ws and zod copies that viem and abitype use. npm's fix is wagmi 3, a breaking upgrade that hasn't been made. CI fails only on critical advisories, so a new critical one still blocks a merge. | package.json |
gasUsed × effectiveGasPrice as the fee, but Injective charges the gas limit at the fee cap and refunds nothing, so a receipt could show less than was paid (20% less for a viem transfer). Receipts now show the fee charged, and Send asks the wallet for the price it quoted. Found by the end-to-end tests; the rule is in injective-core's MsgEthereumTx.GetFee, and RefundGas is disabled.@injectivelabs/wallet-ts package is gone, and npm audit findings fell from 208 to 49.ibc/ or peggy denom shows its name instead of a hash. INJ and USDC are recognised and priced by exact denom only; before, USD totals priced a coin by its label. The hardcoded testnet USDT entry is gone.MsgMultiSend, as the page always said, instead of one transaction per recipient. Each row is checked against the token's rules first..inj names from the Injective Name Service, show the address a name points to, and show a typed address's primary name when it resolves back to that address. A beneficiary saved by name warns when the name has since been pointed at a different address.NEXT_PUBLIC_INJECTIVE_*), with an optional server proxy that keeps a premium EVM RPC key off the client. Send, Payroll, Claims and the claim page check that the endpoint reports the expected chain id and a block from the last minute before allowing a send, re-checking every 30 seconds, and they warn when governance has scheduled a chain upgrade (lib/injective/health.ts).types.ErrorOutOfGas), which Injective's docs say is not a real restriction, wallet transactions and claim payouts now retry once with twice the gas. A real restriction is reported as the token issuer's rule, and NinjaPay says it doesn't screen transfers. Claim payouts and refunds size gas by simulation instead of a fixed 200,000. Keplr/Leap no longer opens an approval window on page load, and the unused NEXT_PUBLIC_ESCROW_WALLET setting is gone.signAndBroadcast re-checks against the simulated fee before the wallet opens. Before, a user with USDC but no INJ got a raw chain error after signing, Max could leave nothing for the fee, and amounts were compared as floats. Send and Payroll now read balances from the account that actually signs (Keplr/Leap for USDC and Payroll).?to= link) accepts inj1… or 0x… and treats them as one account. Before, Send routed INJ by address format (so an inj1… recipient needed Keplr), USDC rejected 0x… recipients, and Payroll and Beneficiaries accepted inj1… only. The dashboard now shows the wallet's inj1… form without asking Keplr for it.1439 on testnet, 1776 on mainnet) everywhere. Before, RainbowKit used inEVM chain 2424 and the "add network" helper used 2408.erc20: denom, 6 decimals) on both networks. The old Peggy USDC.e denom had zero supply on testnet, and several screens divided USDC by 10^18 instead of 10^6.MsgBroadcasterWithPk as a private key.sendToken. Previously they were converted twice, so 1 USDC was requested as 1,000,000 USDC.token column (it used to read the split type), and unwraps params with use() as Next.js 16 requires.transactions table is no longer read or written, so it can be dropped.