@alphea/connect
v0.1.5
Published
User-credentialed ALPHEA Connect RPC SDK for browser product surfaces
Readme
@alphea/connect
User-credentialed ALPHEA Connect RPC SDK for browser product surfaces.
This package is the fourth ALPHEA authority model. It is for app code acting as
a signed-in end user against public alphea.connect.v1 services — not for
operator tooling (@alphea/foundation), not for a served app runtime's ambient
capability session (@alphea/client), and not for host-mediated function grants
(@alphea/fn).
| Package | Authority | Boundary | Credential |
|---|---|---|---|
| @alphea/client | browser_capability_session | browser_capability_gateway | ambient runtime session |
| @alphea/fn | function_host_grant | function_host_bridge | host-mediated grant |
| @alphea/foundation | foundation_bearer_session | foundation_rpc | operator bearer |
| @alphea/connect | connect_user_session | connect_rpc | end-user access credential |
Credential posture
The transport takes a provider, not a credential, and reads it fresh on every call:
import {
createAlpheaConnectClient,
createAlpheaConnectFetchTransport,
} from "@alphea/connect";
const connect = createAlpheaConnectClient({
transport: createAlpheaConnectFetchTransport({
baseUrl: "https://connect.example.com",
getAccessToken: () => sessionStore.currentAccessToken(),
}),
});Three rules hold for every request:
- No cookies, no credentialed CORS. Requests are issued with
credentials: "omit". TheAuthorizationheader is the only credential, so a cross-origin page that does not already hold the user's credential cannot spend their session. - Nothing is stored. The provider result is used for one request and discarded. The credential is never an own-property of the transport or the client, so it cannot be recovered by walking the object graph.
- No server text is surfaced. Errors normalize to a stable
AlpheaCodedErrorwhose message is derived only from the call label and the resolved code. Branch oncode; correlate with the trace id.
baseUrl must be HTTPS except for loopback development endpoints, and only
alphea.connect.v1 services may be targeted.
Auth and session
Google sign-in uses browser-owned PKCE on the Hub origin. The supported
callbacks are https://hub.alphea.dev/auth/google/callback and
https://hub.alphea.ai/auth/google/callback; pass allowedCallbacks to add a
loopback endpoint for local development.
import {
createAlpheaConnectAuthClient,
createAlpheaConnectMemorySessionStore,
} from "@alphea/connect";
const sessionStore = createAlpheaConnectMemorySessionStore();
const auth = createAlpheaConnectAuthClient({ transport, sessionStore });
// 1. Before redirecting. The verifier is stored, not returned.
const start = await auth.beginGoogleLogin({
redirectUri: "https://hub.alphea.ai/auth/google/callback",
clientId,
authorizationEndpoint,
});
location.assign(start.authorizationUrl!);
// 2. On the callback page.
const snapshot = await auth.completeGoogleLogin(location.href);
// { authority: "connect_user_session", authenticated: true, renewable: true, userId }completeGoogleLogin validates the callback origin and path against the
allowlist before reading anything from the URL, then consumes the pending
transaction — deleting it whether the login succeeds or fails, so a replayed
callback cannot re-drive the flow. The redirect_uri presented for redemption
is the one recorded when the flow started, never a value read back out of the
callback.
Email sign-in uses the challenge/verify pair, with OTP or magic-link delivery:
const { challengeId } = await auth.requestEmailChallenge({ email });
await auth.verifyEmailChallenge({ email, code, challengeId });refreshSession(), currentSession(), and logout() complete the lifecycle.
logout() always clears the local session, including when the server call
fails.
No auth method returns a token. A successful login writes the session into the store and returns a redacted snapshot; requests get their credential by wiring the store into the transport:
import { createAlpheaConnectAccessTokenProvider } from "@alphea/connect";
createAlpheaConnectFetchTransport({
baseUrl,
getAccessToken: createAlpheaConnectAccessTokenProvider(sessionStore),
});The default session store is in-memory, so a reload signs the user out. Supply your own store to choose a persistence policy explicitly.
Caller profile
const data = createAlpheaConnectDataClient({ transport });
const profile = await data.profile.get();
// { email, displayName, avatarUrl, avatarKind, isPremium, serverTime }This is the only supported source of a user-facing display name. Do not derive one from the session's email, the account or user id, or Google/token claims: those are authentication material, not a name the user chose to show.
displayName is server-owned and never empty on a read. When the caller has
set no name, the server substitutes a stable non-PII fallback of the form
Alphea User 3F2A91. Render what arrives:
element.textContent = profile.displayName;Do not write profile.displayName || "Alphea User". The server has already
made that decision, and it made the same one for every client — a local fallback
would show this user a different label than the mobile app shows for the same
account. (The proto's "empty = unset" note on display_name is about the stored
value: sending an empty name to UpdateProfile clears it, and the next read
resolves it to the fallback.)
avatarUrl is the genuinely optional field: it is an empty string when the
caller has no avatar, which the contract defines as "use the client default", so
choosing that placeholder is the app's call.
isPremium is derived server-side from an active subscription and is never
client-set. avatarKind is a union including AVATAR_KIND_UNSPECIFIED, so an
avatar kind this build does not know about stays visibly unknown.
Only the read is wrapped. Display-name and photo mutations, subscription receipts, and account deactivation exist on the same Core service but are not part of this package's surface.
Points, referral, rounds, and redeem
import { createAlpheaConnectDataClient, isRedeemAccepted } from "@alphea/connect";
const data = createAlpheaConnectDataClient({ transport });
await data.points.balance();
await data.points.history({ yearMonth: { year: 2026, month: 8 }, page: { pageSize: 25 } });
await data.referral.friends({ query: "al" });
await data.rounds.list({ pageSize: 10 });
await data.redeem.status();Point amounts carry both micros (convenient) and microsText (the exact wire
value), because a 64-bit micros figure can exceed Number.MAX_SAFE_INTEGER and
a balance is the wrong place to lose a digit.
Round display names
Every round read — rounds.status(), rounds.list() and rounds.get() —
carries displayName, the round's operator-given name, exactly as the server
stored it:
// The open round, as a live surface would read it.
const round = await data.rounds.status();
// Empty means the round has no name. Choose your own label; the SDK will not.
const label = round.displayName || `Round ${round.roundNumber}`;Three properties are worth relying on:
- It is presentation only.
roundNumberremains the allocation, ordering and distribution identity. A display name never affects eligibility, lifecycle, Merkle data, a claim proof, or settlement. - Empty is a state, not a missing read. A round whose name was cleared and a
round that never had one both decode to
"". Write the fallback in your own surface, as above — the SDK does not synthesiseRound 21, because a name it invented would be indistinguishable from one an operator typed. - It is carried verbatim. No trimming, casing, or truncation happens here. Numeric round allocation may legitimately skip numbers, so the name and the number answer different questions and neither substitutes for the other.
The field is declared once, on the shared round view, so the status call and the inventory cannot drift into two different readings of the same name.
Redeem outcomes are in the body, not the status code
The redeem mutations report business outcomes in a response enum, not as transport errors. A cap-exceeded redeem returns 200. A resolved promise is not success:
const result = await data.redeem.create({
roundId, // required; see "Submitting a redeem" below
idempotencyKey, // client-minted, 1-128 chars
walletAddress, // required; the server never auto-selects a wallet
amountMicros, // > 0
});
if (!result.accepted) {
// result.outcome is e.g. REDEEM_OUTCOME_CAP_EXCEEDED
}result.accepted and isRedeemAccepted(outcome) are the same check, spelled
two ways so the wrong one is hard to write.
Submitting a redeem
The redeem reads resolve a round when you omit one: the open round if there
is one, otherwise the most recent. The two mutations — create and repin
— do not: they require an explicit roundId and the server rejects a request
without one. So read first, then submit, and submit only when the round the
server returned is actually ROUND_STATUS_OPEN:
const status = await data.redeem.status(); // no argument: open, else latest
// The returned round may be a closed past round, so gate on the status you got
// back rather than on the fact that a round came back at all.
if (status.status === "ROUND_STATUS_OPEN" && status.canRedeem) {
// The server never auto-selects a wallet, and a first-time redeemer has no
// pinned one yet, so choose an ACTIVE linked wallet explicitly.
const wallets = await data.wallet.list();
const wallet = wallets.find((candidate) => candidate.status === "ACTIVE");
if (!wallet) {
// Nothing to redeem into yet — bind one with data.wallet.bind(...).
return;
}
const result = await data.redeem.create({
roundId: status.roundId, // pass it back verbatim
idempotencyKey,
walletAddress: wallet.walletAddress,
amountMicros,
});
}Repinning moves an accepted participation's payout to a different active wallet, so select one that is not the current pin:
if (status.status === "ROUND_STATUS_OPEN" && status.canRepin) {
const wallets = await data.wallet.list();
const next = wallets.find(
(candidate) =>
candidate.status === "ACTIVE" &&
candidate.walletAddress.toLowerCase() !== status.pinnedWalletAddress.toLowerCase(),
);
if (next) {
await data.redeem.repin({ roundId: status.roundId, walletAddress: next.walletAddress, idempotencyKey });
}
}Withdrawing from a round
redeem.withdraw removes the caller's own participation from a round,
entirely, and refunds it.
const result = await data.redeem.withdraw({ roundId: status.roundId, idempotencyKey });
if (result.accepted) {
showRefund(result.refunded); // what was ACTUALLY reversed
} else {
// REDEEM_OUTCOME_NOT_PARTICIPATING — there was nothing to withdraw
// REDEEM_OUTCOME_ROUND_CLOSED — the round can no longer be left
}The request names a round and an idempotency key and nothing else. There is deliberately no redeem identifier: one the caller supplies is one they could change, which would make this a way to ask the server to reverse somebody else's participation. What gets reversed is found from the authenticated caller.
It is all or nothing. There is no partial amount, because a partial reduction has to reason about the round's aggregate floor and can leave a participant holding a total the round was configured to exclude; withdrawing entirely cannot, since nothing remains to be under the floor.
result.refunded is the amount actually reversed, and it is not necessarily
the participation you last read — a redeem an operator cancelled in between is
not refunded twice. Render this figure rather than one computed locally. As
everywhere else on this surface, a resolved promise is not success: read
accepted, and an outcome this build does not recognize is never a completed
withdrawal.
canRedeem and canRepin are server-computed, so gating on them keeps round
lifecycle logic out of your app. The SDK never picks a round or a wallet for
you: omitting roundId is a typed invalid_argument rather than a guess, and
status.pinnedWalletAddress is an empty string until a pin exists — it is the
current pin to compare against, not a wallet to submit with.
Authentication is the transport's job, not an argument here. Compose the client
over a session-backed token provider (see Auth and session) and the caller's
credential travels as the Authorization header only — never in the request
body, the result, or an error.
Retries reuse your idempotency key: the SDK passes it through unchanged and never mints, rotates, or de-duplicates locally. Replay semantics belong to the server, which keys them on the caller and that key.
Contract pinning
ALPHEA_CONNECT_SURFACE mirrors the Connect v1 operations this package
targets, pinned to the Core commit in ALPHEA_CONNECT_CONTRACT_REF. Set
ALPHEA_CORE_CONNECT_SURFACE to a Core surface fixture to cross-check the
mirror against it.
The commit ref names which contract; ALPHEA_CONNECT_CONTRACT_FIXTURE_SHA256
records the digest of that contract's bytes. The accepted surface is vendored in
the repository as a test asset and its digest is verified on every run, with
no opt-in and no skip path: a conformance check that only runs when someone
remembers to set a variable is a gate-shaped thing that is off by default, and a
skipped conformance test reads exactly like a passing one. ALPHEA_CORE_CONNECT_
SURFACE survives only as an operator override for cross-checking against a live
Core checkout, and it must satisfy the same digest — so it can fail a run, never
skip or redirect one. The vendored fixture lives under src/, which the publish
boundary forbids, so it never reaches the package.
data.nonLiveOperations lists mirrored operations that are not live in the
pinned Core contract. It is empty for this surface. A live staging HTTP run is a
separate verification receipt for the deployment, not a reason for the SDK to
mark Core-confirmed operations unavailable.
Wallet binding
A bound wallet is a claim destination, not a login method — binding never creates or changes a session.
The SDK never touches a wallet provider. You supply a two-method adapter, so
your app keeps ownership of window.ethereum, wagmi, WalletConnect, or
whatever it uses:
import { createAlpheaConnectDataClient, type AlpheaConnectSigner } from "@alphea/connect";
const signer: AlpheaConnectSigner = {
getAddress: () => selectedAccount,
personalSign: ({ address, message }) =>
provider.request({ method: "personal_sign", params: [message, address] }),
};
const binding = await data.wallet.bind(
{ walletAddress, chainId, expectedDomain: "hub.alphea.ai" },
signer,
);bind runs the whole flow: check the selected account, request a
server-composed challenge, check it is safe to sign, re-check the selected
account, sign, verify. The account is checked twice because a wallet UI lets
someone switch accounts at any moment, including while a consent dialog is
open — the only check that means anything is the one taken next to the
signature.
The message signed is the server's payload, verbatim. This package never
builds or edits a signing payload; a client that composes its own is a client
that can be talked into signing the wrong thing.
expectedDomain is optional but worth passing. It is checked before the
signing dialog appears, so a payload that did not come from the environment you
expected stops the flow rather than becoming a signature someone approved
without reading.
The individual steps are available as wallet.challenge() and
wallet.verify(), alongside wallet.list() and wallet.unlink(address).
Signatures are credential-shaped: they are in the redaction denylist, and no error raised by this package carries a signature, a payload, or a nonce.
Reward claim
Claiming is the only thing this package does that can move value on a chain, so it is built to make the dangerous step hard rather than convenient. The SDK never decides whether someone may claim — eligibility, allocation, and Merkle authority are the server's. It decides whether what the server said is internally consistent, whether the proof actually proves it, and whether the wallet is where it is supposed to be.
const targets = await data.claim.targets(); // signed discovery
const allocations = await data.claim.list();
const allocation = allocations.rewards[0];
const target = selectClaimTarget(targets, {
chainId: allocation.chainId,
distributor: allocation.distributor,
});
const proof = await data.claim.proof({
chainId: allocation.chainId,
distributor: allocation.distributor,
distributionId: allocation.distributionId,
});
const submitted = await data.claim.send(
{ proof, target, allocation, serverTime: allocations.serverTime },
chainSigner,
);allocation and serverTime are required. The allocation is the second
independent read the proof is checked against — the server's claimable and
claimed gates, the amount, the payout wallet, and the claim window — and
serverTime, from that same claim.list() response, is the only clock allowed
to judge whether the window is still open.
The proof is recomputed locally, before the wallet is touched
The server returns a leaf, an ordered proof, and a root. Trusting all three would leave the server's correctness as the only thing between a wrong allocation and a signed transaction — and a Merkle proof is exactly the artifact that removes that dependency. So the SDK rebuilds the leaf from the fields it is about to encode into calldata and folds the proof up to the published root:
import { hashClaimLeaf, verifyClaimMerkleProof } from "@alphea/connect";hashClaimLeaf reproduces the distributor's own hashLeaf: the six-word
abi.encode of (chain_id, distributor, distribution_id, leaf_index,
wallet_address, amount_base_units), hashed twice. Nodes combine as
keccak256 of the pair sorted ascending as big-endian uint256.
Two things are easy to conflate and both matter: the proof elements are ordered leaf-to-root and reordering them is fatal, while each pair is sorted before hashing — which is what lets the contract verify without direction bits. An empty proof is valid: in a single-leaf distribution the leaf is the root.
A tampered amount, a swapped payout wallet, a proof borrowed from another distribution, or a root from a different tree all fail here, with no wallet prompt and no gas spent. The chain and distributor hashed into the leaf are taken from the signed discovery target, not from the leaf's own copies of them, so the hash is anchored to signed data.
The ABI guard
buildClaimCalldata refuses a distributor whose distributorAbiFingerprint is
not one this SDK can encode for. Both forms operators configure are accepted:
the merkle-distributor-v1 codec label, and the canonical ABI-fingerprint hash
in ALPHEA_CONNECT_DISTRIBUTOR_V2_ABI_FINGERPRINT (compared case-insensitively,
since a keccak digest is the same value in either case). The claim selector is
both derived and pinned — the derivation catches a changed signature, the
pin catches a broken hash.
One send site, and the chain checked twice
There is exactly one place in the package that calls sendTransaction, and a
structural test fails the build if a second one ever appears. Around it:
- the chain is checked before the estimate and again immediately before the send, because a wallet lets someone switch networks while a confirmation dialog is open, and the earlier check has expired by then;
- the gas payer is read explicitly and surfaced, and is deliberately not required to equal the payout wallet. The distributor is permissionless: paying gas on someone else's behalf is supported, and the payout goes to the wallet in the leaf regardless of who sent the transaction. Adding an equality check would look like a safety improvement and would break a working case;
- a failed estimate stops the flow rather than letting the user pay for a revert.
The signer is passed per call, so the client never retains a route to a wallet provider.
A declined dialog is not a failure
try {
await data.claim.send(
{ proof, target, allocation, serverTime: allocations.serverTime },
chainSigner,
);
} catch (error) {
if (isUserRejectedRequest(error)) {
// The person said no. Offer the action again; do not show an error.
}
}isUserRejectedRequest is safe on anything a catch produced, and it has to
be: the two errors a caller can plausibly hold are different shapes. A claim
sent through this package throws a coded error whose provider cause was
deliberately discarded, leaving only a narrow state; a caller that reached its
provider directly still holds the raw EIP-1193 error. Both are recognized.
Underneath, rejection is identified by error code only — EIP-1193 4001, or
ethers' ACTION_REJECTED, including one level of nesting — never by matching
message text, which is localized and is exactly where provider detail would
leak. The provider's own message never travels into the thrown error.
A transaction hash is not a claim
claim.send resolving means the wallet accepted a transaction. It does not mean
the claim happened, and neither does any HTTP 200. The transaction may still be
replaced, time out, revert, or land in a block that is later reorganized away.
Reconcile it against a chain reader you supply:
import { reconcileClaimTransaction } from "@alphea/connect";
const observation = await reconcileClaimTransaction(
{
transactionHash: submitted.transactionHash,
chainId: submitted.chainId,
distributor: submitted.distributor,
distributionId: submitted.distributionId,
leafIndex: submitted.leafIndex,
payoutWallet: submitted.payoutWallet,
},
chainReader, // getTransactionReceipt, getBlockNumber, getChainId
);
if (observation.claimConfirmed) {
// and only then
}| State | What it means for a UI |
|---|---|
| user_rejected | The person declined. Not an error; offer it again. |
| submitted | Accepted, not yet settled. Wait. |
| replaced | The node no longer knows this hash — sped up, cancelled, or dropped. May still land under a different hash, so not a failure. |
| timed_out | No answer within the budget. Says nothing about the outcome. |
| reverted | Mined and rejected by the contract. Gas spent, nothing moved. |
| reorged | A receipt was seen and then unseen or moved. Withdraw anything reported from it. |
| confirmed | Mined, deep enough, and carrying the distributor's own Claimed event for this distribution, leaf, and wallet. |
| unknown | Nothing observable. Never a substitute for the others. |
claimConfirmed is true for confirmed and nothing else. Three checks stand
between a receipt and that word, and each exists because skipping it produces a
confident wrong answer:
- The reader must be on the claim's chain.
getChainIdis read and compared before the first poll, and a mismatch or an unreadable chain refuses outright. A reader aimed elsewhere answers just as confidently about a chain the claim was never sent to. - The receipt must be this claim's receipt. Its
transactionHashmust be present, canonical, and equal to the one being observed — compared case-insensitively, since providers write hashes both ways. Without that, no verdict is reached at all, not evenreverted: telling someone their claim failed on the strength of an unrelated transaction is a wrong answer, not a safe one. A canonicalblockHashis required for the same reason, since a verdict that cannot be re-checked on a later poll cannot be withdrawn when it stops holding. - The
Claimedevent must be there. A successful status is not enough: a transaction can succeed while doing something else entirely, and reporting that as a claim is the exact mistake this step exists to prevent.
getTransaction on the reader is optional and only sharpens the answer —
without it a missing receipt cannot be told apart from a pending one, so
reconciliation keeps waiting instead of guessing replaced.
classifyClaimReceipt is exported for a caller that already holds a receipt and
applies the same gate, including on the head and confirmation-depth arguments it
is handed directly.
The claim window
claimExpiresAt is on both the allocation and the proof: an RFC3339 instant
saying when the on-chain claim window closes. It is server-derived from the
verified publication observed for that distribution, and the SDK treats it as
authoritative rather than computing anything of its own.
An unbound window has no send path. An empty claimExpiresAt means "ask
again", never an open-ended window, so a send that cannot establish when the
window closes is refused rather than attempted — the alternative is telling
someone their claim is live on the strength of a blank field. The read surfaces
still report an empty value losslessly; it is the send that requires a bound,
agreed, server-assessed window.
- Both reads must agree. The proof and the allocation are two reads of one publication, so a disagreement means one is stale and guessing which would be guessing with money. They are compared as instants, so the same moment written with a different UTC offset is agreement rather than a conflict.
- Only the server's clock judges it. The browser's is never consulted: a device wrong by hours would refuse perfectly good claims, and trusting it to permit one is worse. There is no fallback to a local clock.
- The instant must be one that exists. Validation is not "does it parse".
2026-02-30,2026-02-29in a non-leap year,2026-04-31and2026-01-01T24:00:00all match a plausible RFC3339 shape and parse to a finite instant, silently landing on a different day — so every component is range-checked against the real calendar before the value is parsed at all. Padding is refused rather than trimmed, for the same reason a padded amount is: it means the value did not come from the encoder it claims to.
A claim at exactly claimExpiresAt is still open, matching the contract, which
reverts only strictly after it.
BREAKING: claim.send now requires the allocation and the server clock
allocation was optional and serverTime did not exist. Both are now
required, and a call missing either fails closed before the wallet is
touched rather than sending.
// BEFORE — compiled, sent, and silently skipped every cross-check
await data.claim.send({ proof, target }, chainSigner);
await data.claim.send({ proof, target, allocation }, chainSigner);
// AFTER
const allocations = await data.claim.list();
await data.claim.send(
{ proof, target, allocation, serverTime: allocations.serverTime },
chainSigner,
);Omission is refused because of what it silently skipped. Without the
allocation there is no second independent read to check the proof against, so
the server's claimable and claimed gates, the amount, the payout wallet and
the claim window all went unverified — a send with one argument missing was a
send with five checks missing. Without serverTime the claim window cannot be
assessed at all, and an unassessed window has no send path.
Both values come from the same claim.list() response, so the migration is to
keep the response rather than just its rewards array.
Conformance
ALPHEA_CONNECT_CLAIM_LEAF_TYPES and ALPHEA_CONNECT_CLAIM_LEAF_FIELDS are the
canonical tuple this package encodes. The published contract vectors — leaves,
trees, roots, proofs, malformed cases, and out-of-range rejects — are reproduced
byte for byte by the package tests. Set
ALPHEA_CONTRACTS_REWARD_MERKLE_FIXTURE and
ALPHEA_CONTRACTS_V2_HANDOFF_FIXTURE to the contracts fixtures to cross-check
the vendored copies against their source.
Sponsored claim
There are two ways a claim can happen and they are deliberately separate calls.
claim.send asks a wallet to sign and pay. claim.sponsored asks Core to relay
the claim with ALPHEA paying the gas — so there is no wallet, no signature, no
proof, and no calldata anywhere on this path.
const allocations = await data.claim.list();
const allocation = allocations.rewards[0];
const submission = await data.claim.sponsored.submit({
roundId: allocation.roundId,
idempotencyKey: crypto.randomUUID(),
});
const status = await data.claim.sponsored.status(submission.operationId);Two fields go out and nothing else. No wallet address, no amount, no proof, no root, no chain, no distributor, no calldata, and no gas-payer choice: the caller is authorized by their bearer token and Core derives the rest. That absence is the authority model, and the package tests assert it rather than assume it.
The idempotencyKey is yours to mint and this package will never generate
one. What a key means — which requests count as the same request, and when
reuse is a conflict — is Core's rule, and an SDK inventing a key from the round
and the caller would be guessing at that rule. A guess that collided would
silently suppress a second legitimate claim.
The state is the answer, not the status code
Like redeem, a sponsored claim reports its business outcome in the body. A resolved promise means Core replied; it does not mean anything was claimed.
| state | What is true |
|---|---|
| ACCEPTED_FOR_RELAY | Core took the request. Nothing is on a chain yet. |
| SUBMITTED | A transaction exists, and transactionHash identifies it. |
| MINED | A matching receipt was seen. Still not Core's verdict. |
| CORE_CONFIRMED | Core's own Claim state says the claim happened. |
| FAILED | The relay ran and did not succeed. |
| UNAVAILABLE | The sponsor could not act. Not the caller's fault. |
| REFUSED | Core declined; refusal says why. |
isSponsoredClaimConfirmed(state) is true for CORE_CONFIRMED and nothing
else. Mined is not confirmed. A transaction can be in a block and the claim
still not be one Core recognizes, so a UI that treats MINED as done is telling
a user they have been paid on the strength of the wrong fact.
There is no isPending, no isTerminal, and no poller in this package. Whether
FAILED is worth retrying and whether UNAVAILABLE is temporary are Core's
judgements, and how often to ask is a product decision — so status() is a
plain read you call when you want to know, and nothing here advances a state,
derives one from elapsed time, or treats a transaction hash appearing as
progress.
An unrecognized state or refusal is refused, not folded into UNSPECIFIED.
The two mean opposite things — "Core said unspecified" is an answer, "Core said
something this build cannot read" is the absence of one — so a member added to
the contract after your copy of this package was built reaches you as an error
rather than as silence. SPONSORED_CLAIM_REFUSAL_NONE, in turn, is Core stating
there is no refusal, which is a third thing again and is never collapsed into
either.
An omitted state or refusal is a different case and is accepted: a proto3 JSON
encoder omits a field holding its default value, so absence is the contract
saying UNSPECIFIED rather than this package guessing.
Refusals
refusal is a closed vocabulary, and the members are kept apart because they
need different words in a UI:
| refusal | Meaning |
|---|---|
| NO_ALLOCATION | Nothing to claim in this round. |
| NOT_CLAIMABLE | There is an allocation, but the round is not claimable. |
| ALREADY_CLAIMED | Already claimed. The reward is not lost. |
| CANONICAL_INPUT_MISMATCH | Core's freshly derived list/proof/target inputs disagreed. |
| IDEMPOTENCY_CONFLICT | The key was reused for a materially different request. |
| SPONSOR_UNAVAILABLE | The sponsor could not act. Worth retrying. |
What is refused rather than degraded
Elsewhere in this package a missing or wrong-typed field decodes to a zero value
rather than throwing — those are read surfaces feeding a UI, and one odd field
should degrade one number on one card. The sponsored decoders do not get that
latitude, because every field here is one a caller branches on to decide whether
someone's reward moved. On that surface the zero value is not a neutral
fallback, it is a claim of its own: "" says Core sent no transaction, 0 says
Core named no chain, UNSPECIFIED says Core named no state. Folding an
unreadable value into one of those does not lose information, it fabricates a
calmer answer — exactly when the server has gone wrong and you most need to
know.
So the line is not absent-versus-present, and not tolerant-versus-strict:
- Absent is accepted, and resolves to the field's proto3 default. An omitted
field — or an explicit
null— is the default under the JSON encoding, so refusing it would be refusing correct, ordinary responses. - Present but unreadable is refused: an unknown enum name, an out-of-range
or non-integer ordinal, a string field holding a number, a
transactionHashthat is not 32 bytes of hex, achainIdthat is not a canonical uint64 (Number("1e3")is 1000, so a lenient read would invent a chain nobody named).
Three further refusals are about the shape of the answer rather than one field:
- A response that is not a JSON object. An all-empty status reads as
UNSPECIFIED, which is indistinguishable from a legitimate answer about an operation that has not started. - A status that does not identify its operation. The echoed
operationIdmust be present and exactly equal to the one requested — not absent, and not equal-but-for-case. A response that declines to say which relay it describes has not answered the question, and deciding that two ids differing in case name the same operation is Core's rule to make, not this package's. - A body carrying both casings of a field with different values. Resolving
that by key order would make the answer a fact about argument order; on
operationIdit would be a way to slip a mismatched identity past the check above. Presence is about the key existing, sonullunder one casing and a value under the other is a contradiction, not silence — a body saying two different things rather than one thing once. SUBMITTEDorMINEDwith notransactionHash. This one is Core's rule, not ours: "Core never reports SUBMITTED without a hash, because a send whose hash was lost is not a send anybody can follow up on", andMINEDis defined as a receipt for that exact hash. Such a response claims a transaction exists and then declines to name it. The rule stops exactly there —CORE_CONFIRMEDcomes from Core's own claimed state for the leaf, a different authority, so requiring a hash of it would be inventing policy the contract does not state.
Each throws the package's ordinary AlpheaCodedError with code internal, and
none of them echoes the offending value: it came off the network, and these
errors are rendered into pages end users look at.
Decoding is a closed projection — exactly the fields the contract names are copied out. A signer- or provider-shaped field the server should never have sent cannot reach a caller through this path, because the defence is the allowlist rather than a denylist that has to recognize whatever it was called.
Errors, and the one public reason
Every failed call throws an AlpheaCodedError: a stable code, the HTTP-like
status, and a requestId to correlate with the server-side log. The server's
message and details are read only to classify — they never reach the thrown
error, so branch on code, never on message text.
That leaves one gap this package closes deliberately. A signup-gated login is
refused with permission_denied, the same code as any other refusal, so an app
would have nothing to distinguish "you need to sign up" from "you can't do
that". One machine-readable reason therefore crosses the boundary:
import { readAlpheaConnectPublicErrorReason } from "@alphea/connect";
try {
await auth.googleLogin(...);
} catch (error) {
if (readAlpheaConnectPublicErrorReason(error) === "signup_required") {
showSignupPrompt(); // first-time signup is disabled here
} else {
showGenericRefusal();
}
}AlpheaConnectPublicErrorReason is a closed union with exactly one member. The
helper takes unknown, so it is safe on anything a catch produced, and it
returns undefined for everything that is not the exact frozen label —
including a different real server reason, a wrong-case value, a value that
appears only in the message, and a response whose error detail and
Alphea-Error-Reason header disagree with each other. A contradictory response
is not the contract, so no reason is exposed rather than one of the two being
picked.
Nothing else about an error changed: code, status, requestId, conflict,
and notFound behave exactly as before, and no server message, detail, or
metadata value is exposed.
Redaction helpers
redactConnectSession projects any session-shaped value down to presence
booleans and non-secret identifiers. redactConnectPayload returns a structural
copy with credential-bearing fields replaced. Both are safe to call on logging
and error paths.
import { redactConnectSession } from "@alphea/connect";
logger.info("session", redactConnectSession(session));
// { authority: "connect_user_session", authenticated: true, renewable: true }Scope
This release covers the package workspace, the authority table, the transport and client shells, the redaction helpers, the auth/session surface, the points, referral, rounds, and redeem data surface, wallet binding, and reward claim — proof verification, calldata, the single guarded send site, transaction reconciliation, and the sponsored relay mirror.
Out of scope on the sponsored path specifically: this package does not relay, sponsor, sign, or pay for anything. It mirrors Core's submit and status operations and decodes their answers; the relayer, the gas, the idempotency rule, and the claim's confirmation are Core's.
Out of scope by design: wallet-provider UI, any form of server-held or automatic signing, key custody, and RPC endpoint configuration. The SDK holds no route to a wallet or a node; both are supplied per call by the app.
