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)