
Link-Based USDT Transfers Without a Wallet
Sending USDT by link to people with no wallet
Receivers with no wallet, no gas
This was a project to build stablecoin payments on a public blockchain. Transfers on the chain already work, but using them means understanding wallets and gas first. The goal was to send USDT the way you send a link in a messenger, and there were three walls inside that.
First, the recipient has no wallet. The moment you ask someone to create one, twelve seed words show up. The line on the first onboarding screen was the goal itself: 크립토 모르는 친구도 그냥 링크 열면 USDT, KRW 받아요 (a friend who knows nothing about crypto just opens the link and receives USDT or KRW).
Second, even with a wallet there is no gas. To receive a token on chain, the receiving side has to already hold the native coin, and a first time recipient has no reason to have any. The loop of needing money before you can receive money had to break somewhere.
Third, the balance parked in the app just sits there. The balance in the app had to be pay money that earns interest while it sits. In a normal structure, though, the moment A sends to B the asset has to leave the yield system and come back in.
This post covers only the first and second walls, that is, making it possible to receive without a wallet and to receive without gas. The pot structure of KaiaPayVault and the Aave interest accounting that solved the third wall, along with the API on Cloudflare Workers and the type generation pipeline, each deserve their own post, so I'm folding them away here.

Thin server, chain as truth
Privy embedded wallets and ERC-4337 smart wallets erased the word "wallet" from the screen
A user only logs in with email, Google, X or LINE, and never once sees a seed phrase. createOnLogin: "all-users" creates an embedded wallet the moment they log in, and a smart wallet sits on top of it so the actual assets are held by the smart wallet address. The wallet list UI is emptied out entirely with walletList: []. Most of the target users have never touched crypto, so the wallet concept could not be exposed anywhere in onboarding. In exchange, smart wallet creation takes a few seconds right after login, so both home and the link claim screen needed a "지갑을 생성중입니다" (creating your wallet) spinner in a separate wrapper component.
Fees are covered by Kaia's fee delegation transaction type and a server relay
Instead of attaching a paymaster, the chain's native FeeDelegatedSmartContractExecution was used. The server takes the RLP the user signed, signs it once more with the fee payer key, and fires it with klay_sendRawTransaction. An address holding zero can sign its own transaction. The fee payer private key has to live on the server, so it was bound to be readable only through the Workers Secrets Store binding, with auth on the relay endpoint. Broadcasts failed intermittently, so they were wrapped in a three attempt retry.
The transfer link is not stored on the server
When a link is created, a one time private key is generated, compressed with base58, and carried in the URL path; the server keeps only the public address derived from that key. Even if the server DB leaks, nobody can pull someone else's money out. The trade off is that the link becomes a bearer instrument. If it leaks, whoever opens it first takes it. So a 24 hour deadline and the sender's right to reclaim went into the contract, and the UI spells out "다른 사람이 링크를 열지 않도록 주의하세요" (be careful not to let anyone else open this link).

One link, one contract
The whole thing is three blocks: the front end, the edge API, and the contract. What differs is how much each block trusts the others. The front end is a SPA built with Vite and deployed to Cloudflare Pages. Privy handles login and signing, and the browser reads balances directly from the Kaia RPC. The balance number on home is the value of getPot(user, token) read as is, without going through the server. If the server held balances, that number and the chain's number would drift apart eventually, so from the start every number about money looks only at the chain. What the server does instead is own the context a human can read. The contract is a single KaiaPayVault behind a UUPS proxy. A link transfer is expressed inside it as a temporary pot.

Making the link itself a single use wallet
The first time you hit this problem you usually store a claim token on the server. Issue a UUID, put it in a table, and when the recipient opens that URL the server confirms "this token is valid" and sends the money on their behalf from an operations wallet. That is the fastest thing to build. But it turns into a custodial structure where the server holds the unclaimed funds. One DB row is someone else's money, so a leak is a withdrawal and an accidental delete evaporates it. In regulatory terms it is a completely different animal.
When a link is created, a one time private key is pulled with generatePrivateKey(), stripped of the 0x, compressed with base58, and that string goes to the front end only, carried in the /i/{key} path. All the server stores is the publicAddress derived from that key, and the private key disappears the moment the response ends. In the contract the money does not move to that address directly. A temporary pot is created whose owner is the sender and whose deadline is nailed 24 hours out. When the recipient opens the link, the browser reverses the base58 back into the key and uses it to sign transferToken itself, moving the money into their own smart wallet.
The key is not on the server, so the server cannot touch that money. At the same time the pot's owner is the sender, so if the other side never claims it the sender can take it back. The contract's permission check splits those two cases precisely. It passes only when from == msg.sender, or when pots[from][token].owner == msg.sender && owner != from. The first is "the person who got the link pulls it out themselves", the second is "the sender reclaims it". On top of that, a second deposit into the temporary pot address is blocked by require(pots[to][token].owner == address(0)), so the same link cannot be reused. The server records "already claimed" as can_cancel = false, and shows an expiry notice to whoever opens the link second.
Letting an address with zero balance sign for itself and pull the money out
The ideas that come first are sprinkling gas onto the temporary address, or having the server execute the whole thing on its behalf.
The browser signs a FeeDelegatedSmartContractExecution with the link key and sends it to the relay, and the server signs it once more with the fee payer key and broadcasts it.
Signing authority and payment responsibility are split, so the relay can censor but cannot alter the contents. There were a few more traps, like gas estimation failing on a zero balance account, and I'll write that part up separately.
What a user can do
Cancel button still just logs
Honestly, what reached the demo is a mix of flows that work end to end and features that are only screens.
The "취소하기" (cancel) button on the transaction detail still prints console.log("거래 취소"). The contract already has the permission checks and the deadline that reclaiming needs, but the server's transactions.deadline is still null and the code still carries a TODO: 만료 시간 일괄 설정 (set expiry times in bulk) comment. So expiry is something only the chain knows and the server's list does not.
Structurally there are two things I regret. One is that there is not a single line of contract tests. It's a Foundry project with an empty test/ directory. The logic that leaks money the moment it's wrong, like the interest index calculation and the temporary pot permission checks, is exactly what should have been tested first, and it got pushed aside by the mainnet demo schedule. If I built it again I'd start by covering the three permission branches of transferToken with fuzz tests.
The other is that on chain confirmation is triggered by the client. Right now the server state only moves after the front end sends the transaction and then calls confirm-transfer. The server re-verifies the receipt so forgery is blocked, but if the user closes the browser in between, the chain records a success while the DB keeps a pending. If I built it again I'd put an event indexer in place and use the front end call only as a hint to move the screen along faster.














