Feature/payments - #839
Open
gemdev111 wants to merge 54 commits into
Open
Conversation
A payment link resolves to a request or a hosted link, so the decoder stops blanking the recipient on a send. Adds the WalletConnect Pay URL shape, the app metadata a signing payload carries when there is no session, the payment metadata a transaction record keeps, and a neutral home for the signing payload types the gateway and a dApp request both decode into.
The payment gateway hands over a raw method and params rather than a session request, so parsing an action becomes its own entry point and the crate gains request and session features. A payment depends on request only, reusing the same decoder a dApp request uses without pulling session code in.
The gateway owns the decision: which quote is payable, whether the payment is still open, and which actions the wallet has to sign or send. PaymentService dispatches on the link's provider with an exhaustive match, so adding a rail is a compile error until it is handled rather than a runtime failure.
GemPaymentService carries the gateway flow across the FFI with a typed PaymentError, so a decision core makes reaches the apps as a case they can localize instead of an opaque message. Reading a short app name now takes a name rather than a whole metadata record.
Six keys covering the payment sheet, merchant, expiry and the two refusal errors. Generation requires every language to carry the same key set, so the translations land with the English source.
Mappers keep Gemstone out of feature code, so a scanned link arrives as a Primitives value. Adds the expiry row and the web view the compliance step needs, plus the TestKit fixtures the payment suites build on.
A payment has a merchant, not a session, so the transfer-data factory and the sign payload move below WalletConnector where a caller without a session can reach them. PaymentService adapts the gemstone flow and reports payment state back to the transaction scheduler.
An ERC-20 approval a payment needs now goes through the transfer confirmation the user already knows, so an unlimited allowance is reviewed rather than signed in the background. TransferExecutor returns the hashes it broadcast so the caller can wait for the approval to be mined before the next spend.
The flow the gateway drives: fetch options, let the user pick a quote, collect compliance data in a web view when the quote asks for it, then run the actions. Signatures are gathered before spends because signing consumes no nonce, and each approval is confirmed on chain before the next broadcast.
Both a session request and a payment ask the user to sign, so the sheet host stops belonging to WalletConnector. The wallet screen scanner reads payment links behind the developer flag.
Closed
The app composes the payment flow and hosts its sheets, and a deep link or a scanned link reaches it through the shared URL action. Payment errors map to localized copy rather than core's English messages.
Mappers keep the generated uniffi types out of feature code. A relayed payment has no hash the wallet broadcast, so the repository records it against the payment id and reconciles it when the gateway settles.
The payment screen mirrors the iOS flow: quote selection, the compliance web view, then the actions. The sign message review components are shared with the WalletConnect bridge instead of duplicated.
The gateway parses a bounded approval and the wallet was discarding it, requesting an unlimited allowance on every payment. TokenApprovalParams now carries the parsed approval; swap keeps the unlimited default it relies on.
A quote that expires mid-flow was refetched and, if the chosen asset was no longer offered, silently replaced with the first one — a different asset and amount than the buyer picked. It now fails as an expired quote instead.
Carry the payment metadata on Generic confirm params instead of a flag, so the confirmation titles itself as a payment on every chain, and build that metadata in one place for both the recorded transaction and the confirmation. Lead the sign screen with the amount and merchant, list merchant, wallet, network and expiry, and title the quote picker. Give the data collection WebView a definite height and honour the page viewport meta, so CSS viewport units no longer resolve to zero and clip the form away. Open disallowed hosts outside the payment as iOS does, keep third-party cookies, and log why a page failed.
gemdev111
force-pushed
the
feature/payments
branch
from
August 5, 2026 14:56
a0dd460 to
e197bba
Compare
The gateway names the page that collects personal data, and both apps allowed whatever host it named. Reject a collection url that is not on a WalletConnect host, so a compromised response cannot hand the wallet webview an arbitrary origin along with its completion bridge.
The approve is broadcast through ConfirmTransferScene and the gateway's /confirm long-polls while it mines, so waiting for it on device duplicates work the server already does. Matches Android, which never waited. Removes PaymentApprovalExecutor, TransactionConfirmationWaiter, their protocol and mock, and the ChainService and GemstonePrimitives dependencies they were the only users of.
The gateway requires a Client-Id alongside the project App-Id but never validates it, so sending the device id shipped the identity we use for our own device auth to a third party. Send a per-process random id instead: stable across a payment's calls, tied to nothing.
The approval error claimed a broadcast that no longer happens and hid two different failures behind one name: a store read that failed and an asset the wallet does not hold. Let the store throw, keep the guard for the asset, and name it unknownAsset as Android already does. Check the approval is on the chain the gateway asked for while we are here.
WalletConnector and Payments each carried the same presenter: the same pass-throughs to SheetPresenter and the same three signing entry points, differing only in which sheet case they built. Move that to a protocol with default implementations so the two rails cannot drift apart.
Both navigation stacks wrapped their cases in the same navigation chrome and built the confirm and sign-message scenes identically. Give the shared cases one view and the chrome one modifier, so a payment and a WalletConnect request cannot present them differently.
The shared chrome hid a NavigationStack inside a modifier, so views named for a stack no longer showed one. Wrap explicitly and let the modifier apply only the chrome.
PaymentViewModel carried its own stringly-typed Wallet.account(chain) that duplicated getAccount(Chain) in :gemcore. Convert the FFI chain string at the boundary and delegate instead. Moves the wallet-to-gemstone address mapping next to it as gemChainAddresses(), matching iOS's wallet.chainAddresses.
The payment scene hand-rolled a countdown that WalletConnect and transfer will want too; iOS already shares the equivalent from its components package. Moves it to :ui beside the other property items and names the tick interval.
The payment scene passed a no-op where every other ConfirmScreen caller threads navigator::openAcquireAsset, so the buy affordance ConfirmErrorInfo shows on an insufficient balance did nothing.
Payments imported the WalletConnect feature only to show a sign message screen that knows nothing about WalletConnect — its one tie, an import of WalletConnectorService, was unused. Move the screen and the four types only it uses next to the confirm screen they belong with, so neither rail reaches into the other.
The screen moved to Transfer but its test still asked WalletConnector for it, which only the test build catches. Drop the imports the move left behind while here.
SigningRequestService is a low level chain service, but it also held the sheet presenter, its callback and the presenting protocol, none of which touch a chain. Move them to PrimitivesComponents as generic types named for what they are, so the service keeps only what needs Gemstone and the node provider, and each rail keeps just its three signing entry points.
It owns no mutable state, so a struct says that plainly. Its private steps inherit isolation from the caller and no longer declare it themselves.
The sheet presenter is generic but threw a signing request error, so any feature adopting it inherited a vocabulary it has no part in. Give it SheetDismissal and let each rail read that as its own cancellation.
gemdev111
force-pushed
the
feature/payments
branch
from
August 5, 2026 17:23
e97f1d7 to
647df01
Compare
The approval tests asked to approve an Ethereum token against a quote priced in Bitcoin, which nothing checked until the executor started comparing them. Give both a matching asset so they exercise the guard instead of tripping over it.
Nothing in ChainServices used it: every consumer is a feature, the app, or TransactionStateService. It is a gateway client like SwapService, not chain infrastructure like the node, explorer or signing services, and it was the only member there reaching into the database. Moving it also makes the TransactionStateService edge intra package.
It was a package with no service in it: three domain types, a protocol and a factory kept in ChainServices only because the simulator sat beside them. The types move to Primitives, their Gemstone mappings to GemstonePrimitives, and the simulator becomes SimulationService, named for the one thing it does. The factory now takes an asset rather than a chain, which is all it ever looked up, so building signing data no longer needs Gemstone to resolve one. That drops GemstonePrimitives from fourteen files, from the Payments package outright and from the WalletConnectorService target.
TransferServiceTests and SigningRequestServiceTests were never declared in any package, so the plan asked xcodebuild for two targets it could not resolve.
Whether a payment relays its own transaction, and whether a provider reports status, are product rules both apps had reimplemented; core already knew both, so expose them and let the apps read one answer. Cancellation was never called by either app, so the client, the service and the gemstone export go with it. Each method also matched on the provider to reach the one implementation behind it, five times over. The helpers are named for what they return now. normalize said nothing about what it produces, and the service carried the crate's own name on three of them: inside the payment crate, options are payment options.
The gateway hands back the actions to run and the quote they belong to, and nothing tied the two together: an approval could name one chain while the quote was priced on another. iOS caught it in the executor, Android did not, so it went to the confirm screen showing one chain's asset against another chain's account. Validate it where the prepared payment is already validated, so neither app has to remember to.
Both were decided here as well as in core and on Android. Take core's answer, and drop the cancellation the app never called. The mock kept deciding the relay rule for itself, which would have left the tests agreeing with themselves the day the rule changed. Let it be told instead.
The manager held the executor concretely, so its own tests had to build a real one over a real database to reach the code they were checking, and they asserted on the interactor the executor happens to use. Give it a protocol like TransferExecutable, and let the manager tests ask manager questions: which quote was prepared, what reached confirm. One of those questions had never been asked: when the gateway fails to confirm after the transaction is already out, the outcome carries the hash so the payment can still be followed. The method is execute for the same reason TransferExecutor's is.
The gateway counts a quote's expiry in seconds and carries its asset as a string, and both are converted on the way in and back out again on the way to the gateway. Android tests that; iOS did not, and could not easily, because the service builds its own client the way SimulationService does. The conversions live in the mappers, so test them there, next to the other extension tests. Two files had grown to three types where the package keeps to one, or two when a protocol sits with its implementation; the status protocol is consumed on its own by the transaction state service. The rest is what was in the way of reading it: pay went through a private perform with the same signature, the executor sized an array to write into it by index, and the link error was public without a reader outside the feature.
The uniffi GemPaymentService was injected straight into a view model and a repository, so the FFI object crossed three layers and left nothing to fake in a test. Wrap it the way ServiceStatusService and WalletConnectSimulationService wrap theirs: the client stays behind the service, and callers get typeshared models back, named the way iOS names them. The service takes the gateway client as its interface, which lets the mapping be tested: the gateway counts expiry in seconds and the app in milliseconds, and a quote has to reach the gateway byte for byte after a round trip through our own asset ids. SigningRequestApp was a hand written copy of TransactionAppMetadata with every field made non null, which the FFI type it ends up building does not require, so the payment call site had to invent a description and an empty url to satisfy them. Carry the typeshared model instead, with the merchant mapping iOS uses: no description, and the WalletConnect Pay url gemstone already exposes. Relay and status come from core now rather than being decided here too.
The scene took a callback per intent while every recent feature takes a single onAction. Follow AmountScreen: the screen owns the dispatcher, the scenes report what the user did. The scanner gate read the developer flag once when the view model was built, so turning developer mode on in settings left the scan button hidden until the view model was recreated. Read it the way SettingsViewModel does, and label the button with a string.
The payment sign screen showed a merchant's message with no simulation behind it, so a request to permit a suspicious spender arrived with no warning and a live confirm button. Every other signing surface in the app simulates: WalletConnect does it for the same kind of message, and a payment's transactions go through the confirm screen, which does too. Read the simulation the same way, show its warnings, and take the button away on a critical one or once the quote has expired, which the quotes screen already did and this one did not. The simulator parses typed data as text, so the message bytes go over as text like iOS sends them; hex would have parsed as nothing and warned about nothing.
Checking a payment reached into the dao to swap the placeholder id for the settled hash, which is what storeTransactionUpdate already does for every other transaction, and more carefully: it moves swap metadata and merges into an existing row when the chain transaction is already recorded. It also skipped nextTransactionState. Report the outcome as changes, the way iOS does, and let the shared path apply them. The webview reads the allowed host from core now too.
It collects the view model and owns the action dispatcher, which is what a screen does here; the scenes it renders were already stateless.
Prevent enqueuing payment routes from URL actions unless developer mode is enabled by injecting UserConfig into PendingNavigationCoordinator. Switch MessageSigner creation and preview calls to runCatchingCancellable in PaymentViewModel so coroutine cancellation is respected. On iOS, log data-collection failures and surface a specific PaymentLinkError.dataCollection (added to PaymentLinkError) instead of throwing AnyError. Touched: PendingNavigationCoordinator.kt, PaymentViewModel.kt, PaymentDataCollectionScene.swift, PaymentLinkError.swift.
Add docs/PAYMENTS.md with the payment URL decoding, hosted flow, action semantics, validation rules, platform differences, and code map. Update AGENTS.md to reference the Payments doc and core/skills/project-structure.md to list the new payment crate. Add a decision note in skills/decisions.md clarifying that payment protocol and providers live in Core (not the WalletConnect SDK) so apps share one implementation and validation surface.
gemdev111
marked this pull request as ready for review
August 6, 2026 14:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds WalletConnect Pay - scan a merchant's payment code, choose which of your assets to
pay with, enter the name and birth details the merchant requires, and sign. The payment
appears in Activity while it settles. Available on iOS and Android, behind developer mode.
Link decoding and the payment client live in core rather than behind Reown's Pay APIs, so
both apps share one implementation and neither waits on the iOS Reown fork upgrade.
Closes #790
iOS
wallet entry
pick asset to pay with
compliance form
Permit2
sign
settled
Android
wallet entry
pick asset to pay with
compliance form
Permit2
sign
settled