Skip to content

Feature/payments - #839

Open
gemdev111 wants to merge 54 commits into
mainfrom
feature/payments
Open

Feature/payments#839
gemdev111 wants to merge 54 commits into
mainfrom
feature/payments

Conversation

@gemdev111

@gemdev111 gemdev111 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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

Scan
wallet entry
Quotes
pick asset to pay with
Details
compliance form
iOS scan iOS quotes iOS details
Approve
Permit2
Payment
sign
Activity
settled
iOS approve iOS payment iOS activity

Android

Scan
wallet entry
Quotes
pick asset to pay with
Details
compliance form
Android scan Android quotes Android details
Approve
Permit2
Payment
sign
Activity
settled
Android approve Android payment Android activity
`

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.
@gemdev111 gemdev111 mentioned this pull request Aug 5, 2026
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.
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.
@gemdev111 gemdev111 self-assigned this Aug 5, 2026
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.
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
gemdev111 marked this pull request as ready for review August 6, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integrate WalletConnect Pay via WalletKit

1 participant