Portage SDK · developer preview
Offline payments for any EVM wallet
The Portage SDK lets a wallet pay and get paid in USDC with no network on either phone. The payer's phone signs a compact payment, the receiver's phone verifies it offline and knows how safe it is, and whichever phone gets online first settles it on-chain. Keys never leave the user's device, and no Portage contract or service can move or freeze their funds.
What is available today
Portage is built in phases. The SDK's code exists and is held to shared test vectors in three languages, but packaging it for outside developers is Phase 5 work. This table is the honest state of each piece.
| Piece | What it is | Status |
|---|---|---|
ptg-sdk | Headless Kotlin flows: pay, receive, fund, sync, settle, vault operations, carry, fraud claims, restore | Available in the Portage app |
ptg-protocol | Kotlin port of the protocol: codecs, EIP-712, P-256, verification, tier selection, fraud proofs | Available in the Portage app |
ptg-keystore, ptg-db, ptg-chain, ptg-ble | Android implementations of the ports: keys, encrypted storage, owner transactions, Bluetooth sessions | Available in the Portage app |
@portage/protocol | TypeScript protocol library used by the relayer, the attestor and tools | Available in the repository · npm release Coming Soon |
| Relayer and attestor | Reference services, live on Fuji | Live · self-hosting images Coming Soon |
portage-android AAR | One packaged library on Maven with a reviewed public API | Coming Soon |
| HTTP and JSON-RPC adapters | Ready-made relayer, attestor and chain clients (today they live in the app module) | Coming Soon |
| Sample wallet | A minimal app built only on the SDK | Coming Soon |
| Conformance suite | Vectors and a scripted end-to-end run an integrator executes in CI | Coming Soon |
| React Native wrapper | A thin module over the AAR, on demand | Coming Soon |
| iOS | A foreground client paying by QR code | Coming Soon |
| NFC and SMS transports | Tap-to-pay and an SMS uplink for settlement | Coming Soon |
| Mainnet | Avalanche C-Chain with real USDC, after the audit | Coming Soon |
Core concepts
Pay and settle are separate
A Portage payment is a signed message, 141 to about 350 bytes, that one phone hands to
another by QR code or Bluetooth. It is a claim on the payer's money until it
settles on-chain. Settlement happens later, when either phone, or any phone carrying the
message, reaches a network. Until the chain reports it at the
finalized block, the payment is pending, never "received".
Two balances
- Main balance: USDC at the user's ordinary address. Spendable online, or offline without protection for the receiver.
- Offline balance: USDC the user locked in a personal
OfflineVault. It leaves only against a voucher signed by the phone's hardware-bound key, or to the owner after a withdrawal delay of 3 to 30 days.
Assurance tiers
Offline double-spending cannot be prevented by software; Portage bounds it. Every payment arrives with a tier, and the receiver sets limits per tier.
| Tier | Message | Backing | Risk left with the receiver |
|---|---|---|---|
| T0 Unsecured | DIRECT | The payer's main balance | The payer may have spent it. Small limits by default. |
| T1 Vault-backed | VOUCHER | Locked vault funds, shown by a signed snapshot | The payer over-issued to several people; first to settle is paid. |
| T2 Bonded | VOUCHER | T1 plus a slashable bond with on-chain fraud proofs | Covered for a single over-spend. |
| T3 Reserved | RESERVED | Funds set aside for this receiver alone | None, if settled before the reservation expires. |
The rule that makes offline safe
A receiver accepts a voucher only if it expires before the earliest moment the vault
could be emptied: validBefore ≤ Ts + D − m, where Ts is the
snapshot time, D the vault's withdrawal delay and m a safety
margin. "Pay offline, then drain the vault online" therefore cannot work. The SDK applies
this check for you in ReceiveFlow.accept.
Two keys per user
- Owner key (secp256k1): derived from the 12-word recovery phrase; signs direct payments, deposits and vault operations.
- Voucher key (P-256): generated inside the phone's secure hardware (StrongBox or TEE), never exportable; signs vouchers. The vault verifies it on-chain through Avalanche's P-256 precompile.
Architecture
The SDK is a set of Kotlin modules. The flows hold all payment logic and talk to the outside world only through small interfaces, called ports. Your app supplies the UI and picks implementations for the ports, most of which ship as modules.
- Pure JVM core.
ptg-sdkandptg-protocolhave no Android or UI dependency, so every flow unit-tests on a laptop with fakes. - One wire format, three languages. Kotlin, TypeScript and Solidity all read the same vectors in
tools/vectors/*.json, so a payment made by one wallet verifies everywhere. - Services are optional. With no relayer or attestor, the wallet still pays and settles from the owner's own address over any RPC ("rescue mode").
Installation Coming Soon
A packaged portage-android library on Maven Central, with a reviewed and
semantically versioned public API, is planned for Phase 5. When it ships, adding it will
look like this:
dependencies {
// Coming Soon: coordinates and version are not published yet.
implementation("org.portage:portage-android:1.0.0")
}
Requirements
| Android | 10 (API 29) or later. Animal Sniffer guards the modules against newer APIs. |
|---|---|
| Kotlin / JVM | Kotlin with coroutines; JVM target 17. |
| Hardware | A hardware-backed keystore (TEE or StrongBox) for the voucher key. Phones without one fall back to a software key with lower limits, shown to receivers. |
| Network | Avalanche Fuji today. Avalanche C-Chain and other EVM chains Coming Soon. |
Quick start
This is how the Portage app wires the SDK today. The samples use the real class names; treat them as an API preview that may change before 1.0.
1. Shared setup
val registry = Registry.DEFAULT // networks, tokens, contracts, attestor keys
val network = registry.get(Net.AVALANCHE_FUJI)!! // Fuji today
val clock = DeviceClock { System.currentTimeMillis() / 1000 }
val rng = SecureRandom()
val random = RandomSource { n -> ByteArray(n).also(rng::nextBytes) }
// ptg-db: one encrypted database implements every store the flows need
val db = PortageDatabase.open(context)
val time = TimeGuard(clock, db) // refuses to sign or accept if the clock was set back
// ptg-keystore: keys never leave the keystore; every signature asks for the user
val owner = OwnerKeystore(context, authenticator, promptFor = ::authPrompt)
2. Receive a payment
val receive = ReceiveFlow(registry, network, self = owner.address, store = db, time = time, random = random)
// Show a request as a QR code. amount 0 lets the payer choose.
val (request, qrText) = receive.newRequest(amount = BigInteger.valueOf(2_500_000)) // 2.50 USDC
// Later, with the payer's signed payment (scanned text or Bluetooth bytes):
when (val r = receive.accept(scanned, request)) {
is ReceiveFlow.Outcome.Accepted -> showPending(r.record) // verified, then stored: PENDING
is ReceiveFlow.Outcome.AlreadyReceived -> showPending(r.record) // scanned twice: not an error
is ReceiveFlow.Outcome.Rejected -> explain(r.code) // E_SIG, E_EXPIRY, E_POLICY, …
ReceiveFlow.Outcome.ClockBlocked -> askToFixClock()
}
3. Pay
val pay = PayFlow(registry, network, signer = owner, store = db, time = time, random = random)
val request = (pay.parse(scannedRequest) as? PayFlow.Parsed.Ok)?.request ?: return
when (val q = pay.quote(request)) {
is PayFlow.QuoteResult.Ok -> {
// q.quote.options lists every tier with its fee, or why it is blocked
when (val s = pay.sign(q.quote)) { // biometric prompt here
is PayFlow.Signed.Ok -> showQr(s.record.framed) // "PORTAGE:…" for the payee to scan
PayFlow.Signed.Cancelled -> Unit // nothing was signed
else -> explain(s)
}
}
is PayFlow.QuoteResult.TierUnavailable -> explainTiers(q.options)
PayFlow.QuoteResult.AmountRequired -> askForAmount()
PayFlow.QuoteResult.ClockBlocked -> askToFixClock()
}
For vault-backed (T1/T2) and reserved (T3) payments, also pass a VoucherIssuer
and a ReservedIssuer to PayFlow, and the vault and reservation
stores to ReceiveFlow. Without them, the flows handle direct (T0) payments only.
4. Settle in the background
val queue = SettlementQueue(
registry, network, store = db, chain = rpc, // ChainPort: reads at `finalized`
relayers = { listOf(relayer) }, // tried in order, with backoff
time = time, clock = clock,
)
// From WorkManager when online, and on app start:
val summary = queue.runOnce()
// summary.settled, summary.expired, summary.selfSubmittable (payments the user can settle with own gas)
rpc and relayer are your implementations of ChainPort and
RelayerPort. The app's JSON-RPC and HTTP clients will ship as SDK adapters
Coming Soon.
Ports you provide
Each flow takes the ports it needs in its constructor. Implement them yourself, or use the Android modules that already implement them.
| Port | Responsibility | Shipped implementation |
|---|---|---|
OwnerSigner | secp256k1 owner key; returns 65-byte signatures after user authentication | ptg-keystore · OwnerKeystore |
VoucherSigner | P-256 voucher key in secure hardware; low-s signatures over EIP-712 digests | ptg-keystore · VoucherKeystore |
PaymentStore, VaultStore, SnapshotStore, IssuanceStore, ReservationStore, CarryStore, SyncStore, … | Local state; issuance writes are single transactions with synchronous = FULL | ptg-db · PortageDatabase (SQLCipher) |
ChainPort, VaultChainPort, EarmarkChainPort, … | Chain reads at the finalized tag | App JSON-RPC client · SDK adapter Coming Soon |
RelayerPort, VaultRelayerPort, OwnerOpRelayerPort, FraudRelayerPort | Relayer HTTP API | App HTTP client · SDK adapter Coming Soon |
AttestorPort | Attestor HTTP API; answers are untrusted until verified against pinned keys | App HTTP client · SDK adapter Coming Soon |
DirectSubmitter, CallSender | Rescue mode: transactions from the owner address, paying gas | ptg-chain |
DeviceClock, RandomSource | Wall clock and secure randomness | One line each (see Quick start) |
Flows
Each flow is a class with suspend functions and sealed result types. None of them shows UI; each returns what your screens need.
Paying and receiving offline
- Request. The payee's
ReceiveFlow.newRequestreturns aREQUEST(41–81 bytes) shown as a QR code. With Bluetooth it also carries an ephemeral X25519 key and an advertising tag. - Quote.
PayFlow.quotelists every tier with its fee and expiry, vault tier first, and says why any tier is blocked. - Sign.
PayFlow.signasks for the fingerprint or PIN, with the amount and payee shown in the system prompt itself. - Verify, then store.
ReceiveFlow.acceptchecks signature, payee, expiry, snapshot, the expiry rule and the receiver's limits, and stores the payment only if it passes. - Settle.
SettlementQueue.runOncesubmits to relayers and reads the outcome from the chain atfinalized, never from the relayer.
Funding the offline balance
FundingFlow.quote(amount) returns the relayer's deposit fee or why funding is not
possible now. fund(amount, fee, unlockDelaySec) creates the voucher key on first
use, predicts the vault address locally, and signs one deposit authorisation. The relayer
deploys the vault and deposits in one transaction. refresh() reports
Done only when the deposit is visible at finalized.
Issuing a voucher safely
VoucherIssuer enforces this order; a crash can never produce two vouchers with the same sequence number.Vault lifecycle
VaultOpsFlow relays each owner operation as a signature; it is done only when the chain shows it at finalized.
The withdrawal delay is what protects receivers, so it cannot be skipped. A withdrawal
rotates to a new voucher key and starts a new epoch. After a restore from the recovery
phrase, RestoreFlow.discover() finds the vault from the chain alone and guides
the user through this same unlock.
Carrying payments for others
Sync
SyncFlow.run() runs whenever the app is online and in the foreground, and daily in
the background. Each step is independent, so one failure does not stop the others:
- chain time at
finalized, recorded for the clock guard; - the user's own vault state, read directly from the chain;
- an attested snapshot, verified against the attestor key pinned in the registry;
- vaults of recent counterparties, for checking their vouchers offline;
- the signed fee schedule, the signed deny-list of slashed vaults, and the main balance.
Fraud evidence and claims
When a received voucher fails because the vault could not pay, FraudFlow.run()
checks for a fraud pair locally and through the relayer's evidence exchange. Once the
payer's vault is slashed, it asks a relayer for the claim step that is due (file, settle,
collect) and marks the payment CLAIMED when the chain shows the payout.
Payment states
InboundState). Only SETTLED and CLAIMED mean the money arrived.
Sent payments use OutboundState: SIGNING → ISSUED →
SETTLED, EXPIRED or FAILED. HANDED_OFF marks a
received payment given to a courier. Reminders.due(…) tells you when to notify
the user 24 and 6 hours before a pending payment's deadline.
API reference Preview
Package org.portage.sdk. Every flow is safe to call from a coroutine; signing functions suspend while the user authenticates.
PayFlow — pay a request
parse(text: String): Parsed- Decode a scanned request.
Ok(request)orInvalid(code); rejects other networks and non-requests. quote(request, openAmount?): QuoteResultOk(quote)with everyTierOption;TierUnavailable,AmountRequired,ClockBlocked. Never prompts.Quote.withTier(t): Quote?- The same payment with another available tier.
suspend sign(quote): SignedOk(record)withrecord.framedto show;Cancelled,ClockBlocked,VaultRefused,ReservedRefused,SelfCheckFailed.
ReceiveFlow — request and verify
newRequest(amount, acceptsT0 = true, ephPub?, bleTag?): Pair<RequestMsg, String>- A fresh request and its
PORTAGE:text. accept(input, request?): OutcomeAccepted(record, warnings),AlreadyReceived,Rejected(code),ClockBlocked. Stores only after full verification.
FundingFlow — create and fund the vault
suspend quote(amount): QuoteReady(amount, fee, vault, creates, unlockDelaySec),Insufficient,RelayerUnreachable,Busy.suspend fund(amount, fee, unlockDelaySec): Status- One owner signature;
Pendinguntil the chain shows it. suspend refresh(): StatusNone,Pending,Done,Failed(code), read atfinalized.
SettlementQueue — settle what was received
suspend runOnce(): Summary- Earliest deadline first: read status, then submit to relayers with backoff (30 s, 2 min, 10 min, then hourly).
Summary.selfSubmittablelists payments the user can settle with their own gas. suspend selfSubmit(hash): InboundRecord- Settle one payment from the user's own address (needs the user).
SyncFlow — refresh what the wallet knows
suspend run(): Report- Which steps succeeded: chain time, own read, attested snapshot, counterparties, fee schedule, main balance, deny-list.
feeSchedule(),lastMainBalance(),isDenied(vault)- Cached, verified values for offline screens.
VaultOpsFlow — unlock, cancel, withdraw, reservations
suspend requestUnlock(),cancelUnlock(),withdraw()- Relayed owner operations;
refresh()reports completion atfinalized. suspend extendReservation(payee, newExpiry),reclaimReservation(payee)- Manage T3 reservations.
view(),pending()- The vault's status and the operation in flight, for the UI.
CarryFlow, FraudFlow, RestoreFlow, TimeGuard
CarryFlow.scan(text),runOnce()- Take payments to settle for others; submit them when online.
FraudFlow.run(),selfSend(hash)- Evidence and claims for payments a vault could not pay.
RestoreFlow.discover()- Find the user's vault from the chain after a restore.
TimeGuard.now()Ok(now)orBehindChain: the device clock was set back since the last sync.
TypeScript developers can use @portage/protocol (encode, decode, verifyInbound, EIP-712 helpers, fraud proofs) from the repository today; an npm release is Coming Soon.
Wire format
Messages are binary. As text, for QR codes, they are the prefix PORTAGE: followed
by Base45 (RFC 9285), which fits QR alphanumeric mode. Over Bluetooth and HTTPS the raw bytes
are sent. Every message starts with a 4-byte header:
| Type | Message | Length | Signed by |
|---|---|---|---|
0x10 | REQUEST | 41, 49, 73 or 81 bytes | — (shown by the payee) |
0x01 | DIRECT (T0) | 141 bytes | Owner key, EIP-3009 authorisation |
0x02 | VOUCHER (T1/T2) | Base plus an optional attested snapshot | Voucher key (P-256) |
0x03 | RESERVED (T3) | 124 bytes | Voucher key (P-256) |
0x20 | BUNDLE | Up to the courier limits | Carries other messages |
Decoders reject an unknown version or type, check the exact length before reading any field, and reject trailing bytes. Wallets built on the SDK interoperate by default; an integrator may restrict what it accepts by policy but must not change the format under the same version.
Error codes
Stable identifiers. Map each to a sentence the receiver can read to the payer.
E_FRAMEE_VERSIONE_TYPEE_LENGTHE_NETE_PAYEEE_AMOUNTE_DUPLICATEE_SIGE_EXPIRYE_POLICYE_NO_SNAPSHOTE_ATTESTORE_CLOCKE_DENIEDE_EPOCHE_UNLOCK_RISKE_OVER_ISSUEE_CAPE_FUNDSE_EQUIVOCATIONE_NON_MONOTONICE_NO_RESERVATIONE_STALEW_FEE
Relayer and attestor
Both services are thin and replaceable. Neither holds user funds or keys. The app accepts a
list of relayers and a set of attestors, and works with none of them if the user has gas.
Both limit each client address to 120 requests a minute (answered 429 RATE_LIMITED).
Relayer · https://relay.portagewallet.xyz Live on Fuji
| Endpoint | Body | Answer |
|---|---|---|
POST /v1/submit | Raw envelope bytes (application/octet-stream) | { id, status, txHash?, code? }; a BUNDLE gets { results: [...] } |
GET /v1/status/:id | — | { status, txHash?, blockNumber?, finalized } |
POST /v1/deposit | JSON deposit authorisation | { id, status, vault, txHash? }; deploys the vault if needed |
POST /v1/owner-op | JSON owner operation and signature | { id, status, txHash? } |
POST /v1/reclaim | { net, vault, payee } | Reclaim an expired reservation's remainder |
POST /v1/evidence | Raw VOUCHER bytes | Conflicting vouchers that prove fraud, if any |
POST /v1/claim | Raw VOUCHER bytes | The claim step that is due for an unpaid voucher |
GET /v1/fees/:net | — | Minimum fees, in token base units |
GET /v1/health | — | { ok, rpc, db, finalized, hotKeys, build } |
Statuses: QUEUED, SUBMITTED, INCLUDED, FINALIZED, REJECTED(code), REVERTED(reason), EXPIRED. A relayer's answer is advice; the SDK reads settlement from the chain.
curl https://relay.portagewallet.xyz/v1/health
curl https://relay.portagewallet.xyz/v1/fees/2
Attestor · https://attest.portagewallet.xyz Live on Fuji
| Endpoint | Answer |
|---|---|
GET /v1/snapshot/:net/:vault | Vault state read at finalized from two providers, signed |
GET /v1/fee-schedule/:net | The signed fee schedule the app shows |
GET /v1/deny-list/:net | Signed list of slashed vaults (sorted addresses, Merkle root) |
GET /v1/health | { ok, finalized[], build } |
POST /v1/device-attestation | Device integrity flag for snapshots Coming Soon |
Attestor answers are untrusted until verified: the SDK checks every signature against the attestor keys pinned in the registry. Mainnet will use three attestors with a two-of-three threshold Coming Soon. Self-hosting images for both services are Coming Soon.
Networks and contracts
| Network | Registry id | Chain id | Token | Status |
|---|---|---|---|---|
| Avalanche Fuji | 2 | 43113 | Test USDC (Circle faucet) | Live |
| Avalanche C-Chain | 1 | 43114 | USDC | Coming Soon after the audit |
| Other EVM chains, other tokens | More EVM chains; tokens with EIP-3009 or Permit2 | Coming Soon | ||
Fuji deployment
| Settler (USDC) | 0x136db51FDdA4A6dB5eaa9f049E562E896416e694 |
|---|---|
| Vault factory | 0x806b6B4eBd563e40FE6e269C11BAC0Ec080Af0Cb |
| Vault implementation | 0x82e86Ad60c5F8C6BdaC13D67EaD343c93C2deb5F |
| Attestor 1 | 0x7AC82e18518F7ab48aec9D13D8ff71B3D9E99f26 |
Source is verified on Snowtrace. The contracts have no owner, admin, pauser, upgrade path or fee switch. The SDK ships these addresses in its registry; nothing about them is configured at runtime.
Integrator rules
Building on the SDK means keeping the promises it makes to users. These are conditions of use, not suggestions.
finalized. Never "received", "paid", "instant" or "guaranteed" before that.ReceiveFlow.accept; never save or display a payment as received before it passes.Security model
- Keys stay on the device. The owner key is encrypted under a keystore key that needs user authentication on every use; the voucher key is generated in secure hardware and cannot be exported.
- The contract is the enforcement point. Limits that matter live in the vault: the per-payment cap, the withdrawal delay, and the epoch rules. App limits protect the user only.
- Hostile input. Every decoder checks length first and rejects unknown versions, types and trailing bytes; nothing is allocated from an attacker-supplied length.
- Crash safety. Issuance is reserve → sign → commit, so a crash never reuses a sequence number.
- Clock safety.
TimeGuardrefuses to sign or accept after the clock was set back, and uses chain time when the device clock is far off. - Audit. An independent audit, bug bounty and penetration tests come before mainnet Coming Soon.
Roadmap
- Direct paymentsT0 payments, relayer, Android app foundation.
- Offline vaultVouchers (T1), attestor, rescue mode; two phones paid offline and settled on Fuji.
- Assurance and reach · nowBonds and fraud proofs (T2), reservations (T3), Bluetooth, carrying; public testnet beta.
- Hardening and pilot Coming SoonAudit, three attestors, device attestation, NFC and SMS, mainnet, a first field pilot.
- SDK and more chains Coming SoonThe
portage-androidpackage, npm 1.0, sample app, conformance suite, self-hosting images, a second chain and token, iOS.
Support
Follow progress and reach the team on X @Portagewallet. Try the wallet itself from the download page.