feat(x402): enhance gasless payment handling with balance checks and transaction expiration
Kim committed
Jun 4, 2026 at 22:00 UTC
b76fca0c48b6e15243dd2f7ab19fde291dab19e5
5 files changed
+207
-63
cmd/payment-app/static/index.html
+137
-40
@@ -99,16 +99,127 @@
99
statusEl.textContent = value;
100
}
101
102
- function supportsPayment(candidate) {
102
+ function pickAccount(accounts) {
103
+ return accounts.find((candidate) => Array.isArray(candidate.chains) && candidate.chains.includes(config.network)) || accounts[0] || null;
104
+ }
105
+
106
+ function normalizeAccounts(value) {
107
+ const accounts = Array.isArray(value) ? value : value?.accounts || (value?.account ? [value.account] : []);
108
+ return accounts.map((account) => {
109
+ if (typeof account === 'string') {
110
+ return { address: account };
111
+ }
112
+ return account && typeof account.address === 'string' ? account : null;
113
+ }).filter(Boolean);
114
+ }
115
+
116
+ function standardPaymentWallet(candidate, index) {
117
const features = candidate?.features || {};
104
- return Boolean(
105
- features['standard:connect'] &&
106
- (features['sui:signTransaction'] || features['sui:signTransactionBlock'])
107
- );
118
+ const connectFeature = features['standard:connect'];
119
+ const signTransactionFeature = features['sui:signTransaction'];
120
+ const signTransactionBlockFeature = features['sui:signTransactionBlock'];
121
+ const connect = typeof connectFeature?.connect === 'function' ? connectFeature.connect.bind(connectFeature) : null;
122
+ const signTransaction = typeof signTransactionFeature?.signTransaction === 'function' ? signTransactionFeature.signTransaction.bind(signTransactionFeature) : null;
123
+ const signTransactionBlock = typeof signTransactionBlockFeature?.signTransactionBlock === 'function' ? signTransactionBlockFeature.signTransactionBlock.bind(signTransactionBlockFeature) : null;
124
+ if (!connect || (!signTransaction && !signTransactionBlock)) {
125
+ return null;
126
+ }
127
+
128
+ const executeTransactionFeature = features['sui:signAndExecuteTransaction'];
129
+ const executeTransactionBlockFeature = features['sui:signAndExecuteTransactionBlock'];
130
+ const executeTransaction = typeof executeTransactionFeature?.signAndExecuteTransaction === 'function' ? executeTransactionFeature.signAndExecuteTransaction.bind(executeTransactionFeature) : null;
131
+ const executeTransactionBlock = typeof executeTransactionBlockFeature?.signAndExecuteTransactionBlock === 'function' ? executeTransactionBlockFeature.signAndExecuteTransactionBlock.bind(executeTransactionBlockFeature) : null;
132
+ const name = candidate.name || `Wallet ${index + 1}`;
133
+ return {
134
+ id: `standard:${name}`,
135
+ name,
136
+ async connect() {
137
+ const response = await connect();
138
+ return pickAccount(normalizeAccounts(response?.accounts || candidate.accounts || []));
139
+ },
140
+ async signTransaction(account, transaction) {
141
+ if (signTransaction) {
142
+ return signTransaction({ transaction, account, chain: config.network });
143
+ }
144
+ return signTransactionBlock({ transactionBlock: transaction, account, chain: config.network });
145
+ },
146
+ executeTransaction: executeTransaction || executeTransactionBlock ? async (account, transaction) => {
147
+ if (executeTransaction) {
148
+ return executeTransaction({ transaction, account, chain: config.network });
149
+ }
150
+ return executeTransactionBlock({ transactionBlock: transaction, account, chain: config.network });
151
+ } : null,
152
+ };
153
+ }
154
+
155
+ function legacyPaymentWallet(globalName, label) {
156
+ const candidate = window[globalName];
157
+ if (!candidate || typeof candidate !== 'object') {
158
+ return null;
159
+ }
160
+ const signTransaction = typeof candidate.signTransaction === 'function' ? candidate.signTransaction.bind(candidate) : null;
161
+ const signTransactionBlock = typeof candidate.signTransactionBlock === 'function' ? candidate.signTransactionBlock.bind(candidate) : null;
162
+ if (!signTransaction && !signTransactionBlock) {
163
+ return null;
164
+ }
165
+
166
+ const connect = typeof candidate.connect === 'function' ? candidate.connect.bind(candidate) : null;
167
+ const requestAccounts = typeof candidate.requestAccounts === 'function' ? candidate.requestAccounts.bind(candidate) : null;
168
+ const getAccounts = typeof candidate.getAccounts === 'function' ? candidate.getAccounts.bind(candidate) : null;
169
+ const executeTransaction = typeof candidate.signAndExecuteTransaction === 'function' ? candidate.signAndExecuteTransaction.bind(candidate) : null;
170
+ const executeTransactionBlock = typeof candidate.signAndExecuteTransactionBlock === 'function' ? candidate.signAndExecuteTransactionBlock.bind(candidate) : null;
171
+ const name = candidate.name || candidate.walletName || label;
172
+ return {
173
+ id: `legacy:${globalName}`,
174
+ name,
175
+ async connect() {
176
+ const response = connect ? await connect() : (requestAccounts ? await requestAccounts() : null);
177
+ let accounts = normalizeAccounts(response || candidate.accounts || candidate.account);
178
+ if (accounts.length === 0 && getAccounts) {
179
+ accounts = normalizeAccounts(await getAccounts());
180
+ }
181
+ return pickAccount(accounts);
182
+ },
183
+ async signTransaction(account, transaction) {
184
+ if (signTransaction) {
185
+ return signTransaction({ transaction, account, chain: config.network });
186
+ }
187
+ return signTransactionBlock({ transactionBlock: transaction, account, chain: config.network });
188
+ },
189
+ executeTransaction: executeTransaction || executeTransactionBlock ? async (account, transaction) => {
190
+ if (executeTransaction) {
191
+ return executeTransaction({ transaction, account, chain: config.network });
192
+ }
193
+ return executeTransactionBlock({ transactionBlock: transaction, account, chain: config.network });
194
+ } : null,
195
+ };
196
}
197
198
function currentWallets() {
111
- return walletsApi.get().filter(supportsPayment);
199
+ const wallets = walletsApi.get().map(standardPaymentWallet).filter(Boolean);
200
+ [
201
+ ['suiWallet', 'Sui Wallet'],
202
+ ['suiet', 'Suiet'],
203
+ ['ethosWallet', 'Ethos'],
204
+ ['martian', 'Martian'],
205
+ ['surfWallet', 'Surf Wallet'],
206
+ ['glassWallet', 'Glass Wallet'],
207
+ ].forEach(([globalName, label]) => {
208
+ const wallet = legacyPaymentWallet(globalName, label);
209
+ if (wallet) {
210
+ wallets.push(wallet);
211
+ }
212
+ });
213
+
214
+ const seen = new Set();
215
+ return wallets.filter((wallet) => {
216
+ const key = wallet.name.trim().toLowerCase();
217
+ if (seen.has(key)) {
218
+ return false;
219
+ }
220
+ seen.add(key);
221
+ return true;
222
+ });
223
}
224
225
function refreshWallets() {
@@ -116,7 +227,7 @@
227
walletSelect.replaceChildren(...wallets.map((candidate, index) => {
228
const option = document.createElement('option');
229
option.value = String(index);
119
- option.textContent = candidate.name || `Wallet ${index + 1}`;
230
+ option.textContent = candidate.name;
231
return option;
232
}));
233
const hasWallets = wallets.length > 0;
@@ -126,17 +237,12 @@
237
setStatus(hasWallets ? 'Select a wallet and continue' : 'Install a Sui wallet extension');
238
}
239
129
- function pickAccount(accounts) {
130
- return accounts.find((candidate) => Array.isArray(candidate.chains) && candidate.chains.includes(config.network)) || accounts[0] || null;
131
- }
132
-
240
async function connectWallet() {
241
const wallet = currentWallets()[Number(walletSelect.value)];
242
if (!wallet) {
243
throw new Error('No Sui wallet selected');
244
}
138
- const response = await wallet.features['standard:connect'].connect();
139
- const connectedAccount = pickAccount(response.accounts || wallet.accounts || []);
245
+ const connectedAccount = await wallet.connect();
246
if (!connectedAccount) {
247
throw new Error('Connected wallet did not return an account');
248
}
@@ -262,38 +368,29 @@
368
}
369
370
async function signTransaction(wallet, account, transactionBytes) {
265
- const tx = transactionFromBase64(transactionBytes);
266
- if (wallet.features['sui:signTransaction']) {
267
- return wallet.features['sui:signTransaction'].signTransaction({
268
- transaction: tx,
269
- account,
270
- chain: config.network,
271
- });
371
+ return wallet.signTransaction(account, transactionFromBase64(transactionBytes));
372
+ }
373
+
374
+ async function executeSignedTransaction(signed, fallbackBytes) {
375
+ const bytes = signed.bytes || signed.transactionBlockBytes || fallbackBytes;
376
+ const transactionBytes = bytes instanceof Uint8Array ? bytesToBase64(bytes) : bytes;
377
+ if (!transactionBytes || !signed.signature) {
378
+ throw new Error('Wallet did not return a signed transaction');
379
}
273
- return wallet.features['sui:signTransactionBlock'].signTransactionBlock({
274
- transactionBlock: tx,
275
- account,
276
- chain: config.network,
277
- });
380
+ return suiRPC('sui_executeTransactionBlock', [
381
+ transactionBytes,
382
+ [signed.signature],
383
+ { showEffects: true },
384
+ 'WaitForLocalExecution',
385
+ ]);
386
}
387
388
async function executePrepareTransaction(wallet, account, transactionBytes) {
281
- const tx = transactionFromBase64(transactionBytes);
282
- if (wallet.features['sui:signAndExecuteTransaction']) {
283
- return wallet.features['sui:signAndExecuteTransaction'].signAndExecuteTransaction({
284
- transaction: tx,
285
- account,
286
- chain: config.network,
287
- });
288
- }
289
- if (wallet.features['sui:signAndExecuteTransactionBlock']) {
290
- return wallet.features['sui:signAndExecuteTransactionBlock'].signAndExecuteTransactionBlock({
291
- transactionBlock: tx,
292
- account,
293
- chain: config.network,
294
- });
389
+ const transaction = transactionFromBase64(transactionBytes);
390
+ if (wallet.executeTransaction) {
391
+ return wallet.executeTransaction(account, transaction);
392
}
296
- throw new Error('This wallet cannot execute the USDC prepare transaction');
393
+ return executeSignedTransaction(await wallet.signTransaction(account, transaction), transactionBytes);
394
}
395
396
function encodePaymentPayload(payload) {
frontend/nginx.conf
+1
-1
@@ -2,7 +2,7 @@ server {
2
listen 8080;
3
server_name _;
4
access_log off;
5
- error_log /var/log/nginx/error.log warn;
5
+ error_log /var/log/nginx/error.log crit;
6
7
root /usr/share/nginx/html;
8
index index.html;
go.mod
+2
-1
@@ -15,7 +15,7 @@ require (
15
github.com/go-acme/lego/v4 v4.34.0
16
github.com/go-jose/go-jose/v4 v4.1.4
17
github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008
18
- github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a
18
+ github.com/gosuda/x402-facilitator v0.0.3
19
github.com/hashicorp/yamux v0.1.2
20
github.com/hetznercloud/hcloud-go/v2 v2.40.0
21
github.com/knadh/koanf/parsers/toml/v2 v2.2.0
@@ -94,6 +94,7 @@ require (
94
github.com/go-openapi/spec v0.20.4 // indirect
95
github.com/go-openapi/swag v0.19.15 // indirect
96
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
97
+ github.com/golang/protobuf v1.5.4 // indirect
98
github.com/google/btree v1.1.2 // indirect
99
github.com/google/go-querystring v1.2.0 // indirect
100
github.com/google/s2a-go v0.1.9 // indirect
go.sum
+4
-6
@@ -216,10 +216,10 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U
216
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
217
github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008 h1:KuP/5VlPJwqZNyAV5U60C/j8Pc5O8ENkWPTgP7mEvj0=
218
github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
219
-github.com/gosuda/x402-facilitator v0.0.1 h1:Jo4ctVestDMw6B4dkvodZLj6a5rTt6efpoK4VzWoO5k=
220
-github.com/gosuda/x402-facilitator v0.0.1/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
221
-github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a h1:AQoH9Wigm4LhKgHLDTP1/4fnuv7WXC9HA7HzvPEHbKY=
222
-github.com/gosuda/x402-facilitator v0.0.3-0.20260604031337-6baece37375a/go.mod h1:4hLowxzMiNVcLInkoD9BUDuGbzl86Vj3nO++QXh8OYg=
219
+github.com/gosuda/x402-facilitator v0.0.3-0.20260604061634-d3d61cf24500 h1:JHtOeyajeYz21JVmsVSx7A1W7rP9I3jgKvIUn4KdQVo=
220
+github.com/gosuda/x402-facilitator v0.0.3-0.20260604061634-d3d61cf24500/go.mod h1:gTLrERyBeXt1BN6ax7INhx2WQnhfS/oE0MFZ8/l5Ra0=
221
+github.com/gosuda/x402-facilitator v0.0.3 h1:idC2ZIYRaHU8Y4ODxqBwMV9W7EGiRO9sgkqTg0sEQXg=
222
+github.com/gosuda/x402-facilitator v0.0.3/go.mod h1:gTLrERyBeXt1BN6ax7INhx2WQnhfS/oE0MFZ8/l5Ra0=
223
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
224
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
225
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
@@ -422,8 +422,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
422
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
423
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
424
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
425
-github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
426
-github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
425
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
426
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
427
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
portal/x402/payment.go
+63
-15
@@ -239,23 +239,69 @@ func (p *Payment) WritePrepare(w http.ResponseWriter, r *http.Request, sender, r
239
return
240
}
241
242
- coinObjects, err := suischeme.ListOwnedGaslessStablecoinCoinObjects(ctx, p.requirements.Network, sender, p.requirements.Asset, p.payment.Endpoints)
242
+ amount, err := strconv.ParseUint(p.requirements.Amount, 10, 64)
243
+ if err != nil || amount == 0 {
244
+ http.Error(w, "payment amount is invalid", http.StatusInternalServerError)
245
+ return
246
+ }
247
+ coinType, ok := suischeme.GetGaslessStablecoinType(p.requirements.Network, p.requirements.Asset)
248
+ if !ok {
249
+ http.Error(w, "payment asset is not supported", http.StatusInternalServerError)
250
+ return
251
+ }
252
+ client, err := suischeme.NewClientForNetwork(p.requirements.Network, p.payment.Endpoints)
253
if err != nil {
244
- http.Error(w, fmt.Sprintf("list USDC coin objects: %v", err), http.StatusBadGateway)
254
+ http.Error(w, fmt.Sprintf("create Sui client: %v", err), http.StatusBadGateway)
255
return
256
}
247
- nonZeroCoinObjects := make([]suischeme.OwnedCoinObject, 0, len(coinObjects))
248
- for _, coinObject := range coinObjects {
249
- if coinObject.Balance == 0 {
250
- continue
251
- }
252
- nonZeroCoinObjects = append(nonZeroCoinObjects, coinObject)
257
+ defer client.Close()
258
+ info := suischeme.GetNetworkInfo(p.requirements.Network)
259
+ if info == nil {
260
+ http.Error(w, "payment network is not supported", http.StatusInternalServerError)
261
+ return
262
+ }
263
+ expiration, err := client.ResolveGaslessStablecoinExpiration(ctx, info.ChainDigest)
264
+ if err != nil {
265
+ http.Error(w, fmt.Sprintf("resolve payment transaction expiration: %v", err), http.StatusBadGateway)
266
+ return
267
}
268
269
var prepareTransaction *struct {
270
Transaction string `json:"transaction"`
271
}
258
- if len(nonZeroCoinObjects) > 0 {
272
+ balance, err := client.Balance(ctx, sender, coinType)
273
+ if err != nil {
274
+ http.Error(w, fmt.Sprintf("get USDC balance: %v", err), http.StatusBadGateway)
275
+ return
276
+ }
277
+ if balance.AddressBalance < amount {
278
+ needed := amount - balance.AddressBalance
279
+ if balance.CoinBalance < needed {
280
+ http.Error(w, fmt.Sprintf("insufficient USDC balance: need %d, address balance %d, coin object balance %d", amount, balance.AddressBalance, balance.CoinBalance), http.StatusBadRequest)
281
+ return
282
+ }
283
+ coinObjects, err := client.ListOwnedCoinObjects(ctx, sender, coinType)
284
+ if err != nil {
285
+ http.Error(w, fmt.Sprintf("list USDC coin objects: %v", err), http.StatusBadGateway)
286
+ return
287
+ }
288
+ nonZeroCoinObjects := make([]suischeme.OwnedCoinObject, 0, len(coinObjects))
289
+ var prepareAmount uint64
290
+ for _, coinObject := range coinObjects {
291
+ if coinObject.Balance == 0 {
292
+ continue
293
+ }
294
+ if prepareAmount > ^uint64(0)-coinObject.Balance {
295
+ http.Error(w, "USDC coin object balance sum overflows uint64", http.StatusBadGateway)
296
+ return
297
+ }
298
+ prepareAmount += coinObject.Balance
299
+ nonZeroCoinObjects = append(nonZeroCoinObjects, coinObject)
300
+ }
301
+ if prepareAmount < needed {
302
+ http.Error(w, fmt.Sprintf("insufficient USDC balance: need %d, address balance %d, coin object balance %d", amount, balance.AddressBalance, prepareAmount), http.StatusBadRequest)
303
+ return
304
+ }
305
txBytes, err := suischeme.BuildCoinObjectsToAddressBalanceTransferTransaction(ctx, suischeme.CoinObjectsToAddressBalanceTransfer{
306
Sender: sender,
307
Recipient: sender,
@@ -263,6 +309,7 @@ func (p *Payment) WritePrepare(w http.ResponseWriter, r *http.Request, sender, r
309
Asset: p.requirements.Asset,
310
CoinObjects: nonZeroCoinObjects,
311
Endpoints: p.payment.Endpoints,
312
+ Expiration: expiration,
313
})
314
if err != nil {
315
http.Error(w, fmt.Sprintf("build prepare transaction: %v", err), http.StatusBadGateway)
@@ -274,12 +321,13 @@ func (p *Payment) WritePrepare(w http.ResponseWriter, r *http.Request, sender, r
321
}
322
323
paymentTxBytes, err := suischeme.BuildGaslessStablecoinTransferTransaction(ctx, suischeme.GaslessStablecoinTransfer{
277
- Sender: sender,
278
- Recipient: p.requirements.PayTo,
279
- Network: p.requirements.Network,
280
- Asset: p.requirements.Asset,
281
- Amount: p.requirements.Amount,
282
- Endpoints: p.payment.Endpoints,
324
+ Sender: sender,
325
+ Recipient: p.requirements.PayTo,
326
+ Network: p.requirements.Network,
327
+ Asset: p.requirements.Asset,
328
+ Amount: p.requirements.Amount,
329
+ Endpoints: p.payment.Endpoints,
330
+ Expiration: expiration,
331
})
332
if err != nil {
333
http.Error(w, fmt.Sprintf("build payment transaction: %v", err), http.StatusBadGateway)