Goal: Use a dedicated payment state machine to manage the full lifecycle of app purchases
(e.g. a typical success path: preprocessing → obtain signature → submit signature → on-chain payment on the frontend → developer VC confirmation → receipt persistence → frontend notification;
in practice, free apps, re-signing, and restore-purchase flows will branch off this path).
State sources and storage:
- In-memory:
PaymentStateMachine.states(runtime state),paymentStateStore(tool-level cache). - Redis:
- State:
payment:state:{userID}:{appID}:{productID}(JSONPaymentState). - Receipt:
payment:receipt:{userID}:{developerName}:{appID}:{productID}(JSONPurchaseInfo{VC,Status}).
- State:
External systems:
- DID Gate (query developer
DID/RSAPubKey). - LarePass (signature initiation and fetch-signature callback).
- Developer checkout service (run by the developer, exposes
AuthService/ActivateAndGrantetc., returns VC based on JWS). - Market frontend (app details/purchase entry, payment flow UI, payment status queries, receiving system notifications).
stateDiagram-v2
[*] --> Init
Init --> NotRequired: no price/no productId
Init --> ErrorMissingDev: missing developer
Init --> ErrorDevFetch: DID lookup failed
Init --> Required: productId & developer ready
Required --> SigRequired: trigger fetch-signature
SigRequired --> SigRequired: syncing/retry
SigRequired --> SigSigned: submit/fetch callback code==0
SigRequired --> SigErrorNoRecord: VC query code==1
SigRequired --> SigErrorNeedReSign: VC query code==2
SigSigned --> PaymentRequired: notify frontend to pay
PaymentRequired --> FrontendStarted: frontend indicates ready
FrontendStarted --> FrontendCompleted: payment_completed(txHash, chainId)
FrontendCompleted --> DevSyncInProgress: start polling VC
DevSyncInProgress --> DevConfirmed: VC code==0
DevSyncInProgress --> DevSyncFailed: polling exhausted/network error
DevConfirmed --> [*]
NotRequired --> [*]
note right of Required
Dimensions overview:
- LarePassSync: not_started/in_progress/completed
- SignatureStatus: required/required_and_signed/error
- PaymentStatus: notification_sent/frontend_completed/developer_confirmed
- DeveloperSync: in_progress/completed/failed
end note
SignatureRequired+ submit signature →SignatureRequiredAndSignedSignatureRequiredAndSigned+ vc(code=0) →SignatureNotRequired+DeveloperSyncCompletedPaymentFrontendCompleted+ vc(code=0) →PaymentDeveloperConfirmed- vc(code=1) →
SignatureErrorNoRecord(suggest retry) - vc(code=2) →
SignatureErrorNeedReSign(suggest re-sign) - Polling failure/network error →
DeveloperSyncFailed
These correspond to five fields in the code:
PaymentNeed,DeveloperSync,LarePassSync,SignatureStatus,PaymentStatus.
Together they form the internal coordinate system of the payment state machine; all external-facing status strings are derived from these dimensions.
-
Payment need (
PaymentNeed)not_required: App does not require payment (free app or no valid price config).required: App requires payment (price config and developer info are ready).error_missing_developer: Developer info is missing in price config; payment flow cannot start.error_developer_fetch_failed: Failed to fetch developer info from DID Gate / developer info service.
-
Developer sync (
DeveloperSync) – VC sync progress between market and developer checkoutnot_started: No VC query/poll to developer checkout has been initiated.in_progress: Currently polling or requesting VC from developer checkout.completed: A definitive result has been obtained (VC received, or confirmed “no record” / “need re-sign”).failed: Polling exhausted or network/service errors prevent a reliable result.
-
LarePass sync (
LarePassSync) – signature sync progress between market and LarePassnot_started: No sign/fetch-signature request has been sent to LarePass.in_progress: A sign or fetch-signature request is in flight; waiting for callbacks or retries.completed: LarePass has returned a result (signature produced or confirmed terminal).failed: Interaction with LarePass failed or was deemed terminal.
-
Signature status (
SignatureStatus) – business semantics around JWSnot_evaluated: Need for signature has not been evaluated (initial state).not_required: Current flow does not require signature (e.g. free app or special scenarios).required: Signature is required, but no valid JWS is available yet (user has not completed signing).required_and_signed: Signature required and a valid JWS is present; can be used to query VC from developer checkout.required_but_pending: A fetch-signature/re-sign flow is ongoing; final signature result pending.error_no_record: Developer side found no payment record for the current signature (suggest retry/restore).error_need_resign: Signature is invalid/expired and must be re-signed.
-
Payment progress (
PaymentStatus) – frontend/on-chain payment progressnot_evaluated: Payment progress not yet evaluated (usually right after init).not_notified: “Payment required” has not yet been notified to the frontend.notification_sent: “Payment required” has been sent to the frontend, but the frontend has not started payment.frontend_started: Frontend has indicated it is starting payment;frontend_datacaptures its context.frontend_completed: Frontend has reported on-chain payment completed (withTxHashetc.).developer_confirmed: Developer checkout has returned VC; payment is fully confirmed.
Represents the current payment state snapshot of a specific user, app, and productId.
Also the JSON stored under Redis keypayment:state:{userID}:{appID}:{productID}.
-
Identity fields:
UserID,AppID,AppName,SourceID,ProductID,DeveloperName
Used to uniquely identify a purchase record in memory and Redis, and to link it to a specific app and developer. -
Developer info:
Developer{Name,DID,RSAPubKey}
Fetched from DID Gate / developer info service; used to build payment data, call developer checkout, and verify VC. -
Aggregated internal dimensions:
PaymentNeed,DeveloperSync,LarePassSync,SignatureStatus,PaymentStatus
These are the five internal axes described above, stored together on the state. -
Associated data:
JWS,SignBody,VC,TxHash,XForwardedHost,FrontendDataJWS/SignBody: Data related to the LarePass sign/fetch-signature flow.VC: Authorization credential returned by the developer checkout service.TxHash: On-chain payment transaction hash.XForwardedHost: Used to construct callback URLs and derive user DID.FrontendData: Context the frontend attaches when callingStartFrontendPayment(entry source, environment, etc.).
-
Metadata:
CreatedAt,UpdatedAt
Used for cleaning up stale states and inspecting when a state was last updated. -
Helper methods (code-level):
GetKey(): Generates theuser:app:productunique key.
- Unified storage: All
PaymentStateinstances are written viaPaymentStateMachineto both an in-memory map and Redis:- In-memory key:
userID:appID:productID(generated byGetKey()). - Redis key:
payment:state:{userID}:{appID}:{productID}.
- In-memory key:
- Access rules:
- Reads: Always read from memory first; on miss, load from Redis and write back to memory (
LoadState). - Writes/deletes: Done through
SaveState/DeleteStatewrappers; business code must not access Redis directly.
- Reads: Always read from memory first; on miss, load from Redis and write back to memory (
- Concurrency and reentrancy:
- The in-memory map is protected by an
RWMutex, and updates use copy–update–replace to avoid races. - VC polling, LarePass interactions, and similar flows have reentrancy guards (e.g.
DeveloperSyncInProgresschecks) so that network flakiness does not cause deadlocks or duplicate work.
- The in-memory map is protected by an
-
Frontend entry points
GET /api/v2/sources/{source}/apps/{id}/payment-status- Function:
GetPaymentStatus(userID, appID, sourceID, xForwardedHost, appInfo) - Use case: Called on app detail/install pages to determine the user's purchase status and render "buy / bought / continue payment" UI.
- Function:
POST /api/v2/sources/{source}/apps/{id}/purchase- Function:
PurchaseApp(userID, appID, sourceID, xForwardedHost, appInfo) - Use case: Called when the user clicks "buy". Drives the state machine to either initiate signing, return payment data, or report "already purchased" depending on the internal state.
- Function:
POST /api/v2/payment/frontend-start- Function:
StartFrontendPayment(userID, appID, sourceID, productID, xForwardedHost, appInfo, frontendData) - Use case: Called when the frontend is about to start on-chain payment (e.g. before opening a wallet), to store context and mark
payment_frontend_started.
- Function:
POST /api/v2/payment/start-polling- Function:
StartPaymentPolling(userID, sourceID, appID, productID, txHash, xForwardedHost, systemChainID, appInfoLatest) - Use case: Called after the frontend reports on-chain success, to write
txHashand start polling the developer checkout for VC.
- Function:
-
LarePass callback entry points
POST /api/v2/payment/submit-signature- Function:
ProcessSignatureSubmission(jws, signBody, user, xForwardedHost) - Use case: Handles the "submit-signature" callback, writes
JWS/SignBody, and advances signature state torequired_and_signed.
- Function:
POST /api/v2/payment/fetch-signature-callback- Function:
HandleFetchSignatureCallback(jws, signBody, user, signed) - Use case: Handles the "fetch-signature" callback, updating
SignatureStatus/LarePassSyncbased onsignedand payload, and continues VC sync when appropriate.
- Function:
-
Backend/internal use
InitStateMachine(dataSender, settingsManager): Initialize the global state machine instance.PreprocessAppPaymentData(ctx, appInfo, userID, sourceID, settingsManager, client): Bootstrap/correctPaymentStatewhen fetching app details.ListPaymentStates(): Debug/monitoring view of current in-process states.
This section maps external status strings returned to the frontend to typical combinations of internal dimensions + key fields.
Implementation may contain additional guard paths, but semantics should remain consistent with this table.
-
purchased- Typical internal combination:
DeveloperSync=completedandVCis non-empty.
- Meaning: User has completed payment and holds a valid license; frontend may show “purchased, ready to install”.
- Typical internal combination:
-
waiting_developer_confirmation- Typical internal combination:
PaymentStatus=frontend_completedandDeveloperSyncnot yetcompleted.
- Meaning: On-chain payment is done, backend is waiting for the developer checkout to return VC.
- Typical internal combination:
-
payment_frontend_started- Typical internal combination:
PaymentStatus=frontend_started.
- Meaning: Frontend has entered the payment flow (e.g. wallet opened / transaction being prepared), but the chain result is not yet final.
- Typical internal combination:
-
payment_required- Typical internal combinations (at least one):
SignatureStatus=required_and_signedand no payment record yet;- or
JWSexists andPaymentStatus=notification_sent.
- Meaning: Signature is ready; backend can return
payment_datato let the user perform on-chain payment.
- Typical internal combinations (at least one):
-
payment_retry_required- Typical internal combination:
SignatureStatus=error_no_recordwhile localJWSis kept andPaymentStatusis at leastnotification_sent/frontend_started/frontend_completed.
- Meaning: Developer side has no matching payment record but there is local evidence of prior payment; frontend should expose a “retry/restore purchase” path.
- Typical internal combination:
-
notification_sent- Typical internal combination:
PaymentStatus=notification_sentand signature/payment have not yet progressed further.
- Meaning: Backend has notified the frontend that payment is required, but the user has not started payment yet.
- Typical internal combination:
-
not_buy/not_notified/not_evaluated- Typical internal combination:
PaymentStatusstillnot_evaluatedornot_notified, andProductIDis non-empty.
- Meaning: No payment/signature flow has started; treat as “not purchased” and show a purchase button.
- Typical internal combination:
-
signature_required- Typical internal combination:
SignatureStatus=requiredorrequired_but_pending.
- Meaning: User needs to complete signing; frontend should guide the user to open LarePass or the appropriate signing UI.
- Typical internal combination:
-
signature_no_record- Typical internal combination:
SignatureStatus=error_no_recordand retry conditions not yet met.
- Meaning: Developer checkout found no payment record for the current signature; frontend should guide the user to retry or use a restore-purchase flow.
- Typical internal combination:
-
signature_need_resign- Typical internal combination:
SignatureStatus=error_need_resign.
- Meaning: Signature is invalid/expired; user must re-sign to continue.
- Typical internal combination:
-
Other error-like states (
error_*)- Typical internal combination:
PaymentNeedis in an error state (e.g.error_missing_developer/error_developer_fetch_failed), orDeveloperSync=failed, etc.
- Meaning: Configuration/network/third-party issues; frontend should let the user retry later or contact support.
- Typical internal combination:
-
Push to frontend (
MarketSystemUpdate)payment_required- Sender:
notifyFrontendPaymentRequired - When: Backend has a ready signature and needs the user to initiate payment
(e.g. duringPurchaseApp/ProcessSignatureSubmissionwhen deciding to sendpayment_data).
- Sender:
purchased- Sender:
notifyFrontendPurchaseCompleted - When: After the state machine processes
vc_received, successfully writes VC and persists the receipt.
- Sender:
- Other state updates (
syncing,waiting_developer_confirmation,signature_no_record,signature_need_resign,
payment_retry_required,not_buy, etc.)- Sender:
notifyFrontendStateUpdate - When:
- LarePass callbacks or VC query results cause internal state transitions to one of these states;
- VC polling reaches its maximum attempts or encounters specific error codes (e.g. 1 = no record, 2 = need re-sign).
- Sender:
-
Push to LarePass (
SignNotificationUpdate)- Topic
market_payment(start-signature)- Builder:
notifyLarePassToSign - When:
- On first time signature is required (
SignatureStatus=required) and a valid callback host is present (e.g. fromstart_payment); - Or when signature is marked
error_need_resignand the state machine decides to restart the signing flow.
- On first time signature is required (
- Builder:
- Topic
fetch_payment_signature(fetch-signature)- Builder:
notifyLarePassToFetchSignature - When:
- During preprocessing or
PurchaseAppwhentriggerPaymentStateSyncsees a state that should pull signature from LarePass
(typically when the user has signed before but local JWS is missing).
- During preprocessing or
- Builder:
- Topic
save_payment_vc(persist VC)- Builder:
notifyLarePassToSaveVC - When: After the state machine processes
vc_receivedand persists the receipt; if the first push might be missed, a compensating push can be triggered viaPOST /api/v2/payment/resend-vc.
- Builder:
- Topic
When product definitions evolve between installs, restore-purchase becomes ambiguous:
- User purchases an app and completes installation.
- The purchased
productIDis deprecated and replaced by a newproductID. - User uninstalls the app and later reinstalls it.
Because the original productID no longer exists, the market cannot tell which historical product the user owns.
Querying every historical productID would bloat price.yaml, and each query would have to flow through LarePass, degrading UX.
The recommended approach is for the frontend to present a “Restore Purchases” page or modal: opening it triggers the market to fetch historical products from GitBot, let the user choose an entry, and then continue with the standard restore flow.