ERC-7946 - Unidirectional Wallet Uplink aka UWULink

Created 2025-05-10
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

Universal Wallet Uplink (UWULink) is a protocol that allows applications (dApps) to request a wallet to make a batch of contract calls in a single atomic transaction without establishing a two-way connection or revealing the user's address to the requester. The protocol defines a compact binary message format using Protocol Buffers suitable for low-bandwidth channels such as QR codes or NFC tags. Two modes of operation are supported: Static Mode, where the request payload directly contains the list of calls, and Programmable Mode, where the payload references a contract that generates the list of calls.

Motivation

dApps often require users to perform multistep interactions, such as approving a token and then executing a swap. Traditionally, accomplishing this has required multiple user confirmations or complex wallet connectivity. Recent standards like EIP-5792 and EIP-7702 introduced ways to batch multiple calls into one atomic operation via JSON-RPC (e.g. wallet_sendCalls). However, current implementations of those solutions assume an active connection between dApp and wallet (e.g. injected provider or WalletConnect session), which creates an additional layer of UX friction. There is a growing need for a privacy-preserving, frictionless workflow where a dApp or product can trigger complex transactions without “connecting” their wallet or needing to disclosing their address to the application.

Several trends highlight this need:

By addressing these points, UWULink aims to enhance user experience with one-shot multi-call transactions and improve security and privacy by eliminating unnecessary data sharing.

Specification

Overview

UWULink defines a protobuf message format and interpretation for transaction requests sent from a dApp to a wallet. The only operation in scope is a request for the wallet to execute an atomic batch of contract calls on an EVM-compatible blockchain. The wallet, upon receiving a UWULink request (for example, via a QR code scan, deep link, or NFC), will decode it, present the details to the user for confirmation, and if approved, execute the calls as a single transaction on the specified chain.

Key characteristics of the protocol:

uwulink:<base64_of_UWULink_message>

The UWULink message includes a oneof/union to indicate which mode is used. Wallets SHOULD support both modes.

Protobuf Schema

Below is the proposed Protocol Buffers v3 schema defining the UWULink message format:

syntax = "proto3";

package org.ethereum.uwulink;

// The top-level UWULink transaction request message.
message UWULinkRequest {
  uint64 chain_id = 1;  // EIP-155 chain ID for the target chain

  oneof request_type {
    Batch batch = 2;
    ResolverReference resolver = 3;
  }
}

// Static batch of calls
message Batch {
  repeated Call calls = 1;
}

// Single contract call
message Call {
  bytes to = 1;                // 20-byte address of target contract
  optional bytes value = 2;    // (optional) up to 32-byte big-endian ETH value
  optional bytes data = 3;     // (optional) calldata for the call
}

// Reference to a resolver contract for dynamic call generation
message ResolverReference {
  bytes resolver_address = 1;  // 20-byte address of resolver contract
  bytes resolver_data = 2;     // opaque data to pass to resolver
}

Notes on the schema:

Wallet and dApp developers can import this .proto to ensure they are constructing and parsing UWULink messages consistently.

Resolver Contract Interface (Programmable Mode)

This standard introduces an interface that resolver contracts must implement so that wallets can query them for call batches. All resolver contracts MUST implement the following ABI (interface identifier UWUResolver):

/// @title UWULink Resolver Interface
interface UWUResolver {
    struct Call {
        address target;
        uint256 value;
        bytes data;
    }

    // Thrown when calls could not be generated, with an error code specific to this resolver.
    error CallGenerationFailure(uint256 errorCode);

    /**
     * @notice Compute a batch of calls for a given request.
     * @param requester The address of the wallet (EOA or contract) that is requesting the calls.
     * @param data Arbitrary request data (opaque to the wallet, provided by dApp via UWULink).
     * @return calls The list of calls that correspond to the requester and request
     */
    function getCalls(address requester, bytes calldata data) external returns (Call[] memory calls);

    /**
     * @notice Returns the details for the given error code. Meant to be called by developers to better understand the error code for a resolver.
     *  Due to localization needs, it is expected that developers may call this function, but the wallet should not show this information to users.
     */
    function getErrorCodeDetails(uint256 errorCode) external returns (string memory information);
}

Wallets should implement the following logic for programmable requests:

  1. Perform an eth_call to resolver_address with to = resolver_address, from = address(0), data = ABIEncodeWithSelector(UWUResolver.getCalls, userAddress, resolver_data) against the latest block. The wallet MAY use the pending block, or otherwise include transactions in the state that are yet to be included in a confirmed block.
  2. If the call returns successfully, decode the result. This becomes the batch of calls to execute. The wallet should then proceed exactly as if it were a static mode request containing those calls. It should display these calls to the user for confirmation (including target addresses, values, and perhaps decoded method signatures if it can).
  3. If the call fails (reverts or is not implemented), the wallet MUST abort. It SHOULD surface an error to the user like "Transaction request generation failed: resolver contract call was unsuccessful." The user then knows the dApp’s request was bad or the contract might be wrong.

The dApp developer and resolver contract developer are responsible for ensuring that calling getCalls is not too gas-intensive to execute (since wallets will execute it off-chain but it still must complete execution). Excessive computation could result in the node returning an error (out of gas exception in the eth_call context). Typically these functions will just gather data from known contracts or encode some predefined calls, which should not be prohibitively expensive.

Example Usage

To illustrate how UWULink can be used in practice, consider the following scenarios:

1. Static Mode – Token Approval and Swap (DeFi use-case):

Alice wants to trade tokens on a decentralized exchange (DEX) using her mobile wallet, but she doesn't want to connect her wallet to the DEX website due to privacy concerns. The DEX dApp prepares a UWULink QR code for the trade. When Alice selects the tokens and amount on the website, the dApp formulates two contract calls: one to the ERC-20 token contract to approve() the DEX's router contract, and one to the router contract to execute the swap ( swapExactTokensForTokens, for example). Normally this would be two separate transactions with two confirmations. Instead, the dApp bundles them:

Both calls have value = 0 (no ETH being sent directly). The dApp encodes these into a UWULinkRequest (static mode) for the current chain (e.g. Ethereum mainnet chain_id 1). The protobuf binary is base64 encoded and placed into a QR code with a URI like:

uwulink:CgEBEiAx...   (truncated)

Alice scans this QR with her wallet app. The wallet decodes the request: chain_id=1, two calls in batch. It recognizes it can execute an atomic batch (Alice’s wallet supports EIP-7702). The wallet UI shows Alice a summary: "This dApp is requesting two actions: (1) Approve Token XYZ for spending, (2) Swap Token XYZ for Token ABC on DEX." Alice can inspect the contract addresses (perhaps the wallet resolves known token/contract names or shows the hex addresses) and the parameters. She sees that both will be submitted together in one transaction. The UI might look similar to a multi-call confirmation screen.

Alice accepts. Her wallet internally either crafts a 0x4 type transaction (since Alice is an EOA on Ethereum) embedding bytecode to do the two calls, or uses its smart wallet module. It then signs and broadcasts the transaction. On-chain, the two calls execute one after the other, and because of atomicity, if the swap were to fail, the approve would be reverted too (avoiding a scenario where she approved tokens without actually swapping).

The DEX backend or frontend can monitor the blockchain for the transaction receipt (it knows what actions it expected, or Alice can manually input the tx hash if needed). The important part is the DEX never learned Alice’s address beforehand; it only sees it when the transaction hits the blockchain, which is unavoidable for executing the trade but at that point privacy is preserved as well as any normal on-chain interaction (the dApp cannot link it to Alice’s web session unless Alice herself tells it out-of-band). This shows how UWULink achieves one-scan confirmation for what used to be multi-step, and keeps Alice’s identity private until the on-chain execution.

2. Programmable Mode – Personalized Airdrop Claim:

A project is running an airdrop where eligible users can claim several different token rewards based on on-chain activity. Bob visits the airdrop dApp page. The page could ask Bob to connect his wallet to figure out what he’s eligible for, but Bob is cautious. Instead, the dApp uses UWULink in programmable mode. It has a resolver contract deployed on-chain which, given a user address, can determine all the reward token contracts and amounts that the user is entitled to claim.

The dApp shows Bob a “Claim Rewards” button, which reveals a QR code. This QR encodes a UWULink request with:

Bob scans this with his wallet. The wallet sees it's a resolver-type request. It calls getBatchCalls(BobAddress, resolver_data) on 0xDeeD...1234 (as a view call). The AirdropResolver contract looks up internally that BobAddress is eligible for 3 tokens: TokenA, TokenB, and TokenC with certain amounts, and the claim function for each is claim(address claimant, uint256 amount) on each token’s distributor contract. It returns three arrays: targets = [AddrA, AddrB, AddrC], values = [0,0,0] (no ETH needed), callData = [ abi.encodeWithSelector(Distributor.claim, Bob, amtA), ... ] for each token.

The wallet receives these arrays. It now has three calls to execute. It shows Bob: "Claim TokenA: amount X, Claim TokenB: amount Y, Claim TokenC: amount Z" (assuming the wallet can decode the function signatures or at least show contract addresses and method names if it has ABIs). Bob approves the batch. The wallet then either directly calls each distributor’s claim in one aggregated transaction. Because Bob’s address was provided to the resolver, each claim call will credit tokens to Bob (likely the contract uses the provided address or msg.sender – here it was likely coded to use the address parameter, since the actual transaction sender will be Bob’s own address in the batch execution context). The important part is Bob did not have to connect his wallet to the dApp; the eligibility and calls were determined by the on-chain contract. The dApp never saw Bob’s address, yet Bob gets his tokens in one go.

After execution, Bob’s wallet shows the transaction success. The dApp might simply tell him to check his balances (or it could have a public page showing which addresses claimed, etc., but it did not get a direct notification — it relies on Bob or the blockchain to know the claim happened).

3. Cross-Device Payment via NFC (Point of Sale):

Carol is at a merchant's point-of-sale device that accepts cryptocurrency payments via Ethereum. The merchant’s device can display a QR or emit an NFC message with a payment request. Instead of using a simple one-address payment URI (as in EIP-681), the merchant uses UWULink to request a more sophisticated transaction: perhaps Carol will pay through a specific escrow contract or with a certain token if she has a discount coupon.

The device sends an NFC payload which Carol’s phone picks up (many wallet apps can register as handlers for certain NDEF messages or custom URI schemes). The payload contains a UWULinkRequest in static mode:

Carol’s wallet opens with the decoded request: It shows "Pay 50 USDC to Merchant XYZ and register purchase." Carol sees the merchant name resolved from the merchant’s address (if her wallet has ENS or a local registry of known merchants). She approves. The wallet then executes an atomic transaction on Polygon that calls the USDC token contract and the registry contract. The merchant’s PoS waits for confirmation on-chain (or simply trust the signed transaction once broadcast, depending on their risk tolerance). Carol’s identity remained pseudonymous; the merchant’s device did not directly get her wallet info, it only received the on-chain payment. And Carol only had to tap once to approve both token transfer and logging, rather than scan one QR to pay then perhaps another to log, etc.

These examples demonstrate the flexibility of UWULink:

Rationale

TBD

Backwards Compatibility

UWULink is an additive protocol and does not break any existing standards. It is designed to coexist with current methods:

In conclusion, UWULink aims to introduce new functionality without disrupting existing user journeys. It is opt-in for all parties. Early adopters (both dApps and wallets) can experiment with it while others continue as usual. As support grows, it could become a widely recognized standard for secure one-way wallet interactions. The design takes into account lessons from previous proposals (EIP-681 URIs, WalletConnect, EIP-5792, etc.) and ensures that adopting UWULink is a low-risk enhancement rather than a breaking change to Ethereum’s ecosystem.

Security Considerations

Privacy

UWULink is designed with privacy in mind, but it introduces some new security aspects that implementers and users should consider:

Security of Transaction Requests

Comparison to Traditional Flows: One might ask, does eliminating the wallet <-> dApp handshake create any new risks? In traditional connected dApp sessions, the wallet at least knows the origin of requests (e.g., which website is calling eth_sendTransaction or wallet_sendCalls). In UWULink, the origin is essentially "the QR code the user scanned" – the wallet might know the payload came via a QR/NFC but not which app or site. In security terms, this means the wallet cannot apply domain-based whitelists or blocklists (since there's no domain, unless the URI contains one in the payload which it typically wouldn't). Therefore, the user must manually trust and verify each request. This is akin to using a hardware wallet: every transaction is shown on a screen and the user approves it, with no assumptions about where it came from. This places responsibility on the user and makes the wallet’s job of displaying info accurately even more important.

Privacy vs. Usability Trade-off: Because UWULink doesn’t let the dApp query the wallet off-chain, some conveniences are lost – e.g., the dApp cannot automatically fetch the user's address to display their balance or NFTs in the UI prior to a transaction. This is a conscious privacy trade-off. Some advanced dApps might find workarounds (like asking the user to input their address manually if they want to see personalized info, or shifting more logic on-chain as in programmable mode). Users and dApp developers must understand this trade-off. In contexts where user personalization without login is needed, UWULink might require a bit more creativity, but it ensures that if the user chooses not to share anything, they truly don't until a transaction is made.

Copyright

Copyright and related rights waived via CC0.