From 5febcd93e7371ef6b0b0f729b62817594f62fa3a Mon Sep 17 00:00:00 2001 From: Georgios Konstantopoulos Date: Sat, 21 Mar 2026 13:27:09 -0700 Subject: [PATCH 1/2] feat: support access key signing via root_account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire root_account into _build_tempo_transfer so that smart wallet access keys can sign transactions on behalf of the wallet: - Use root_account address for get_tx_params (nonce) and estimate_gas - Use pytempo.sign_tx_access_key() when root_account is set, which builds a Keychain V2 signature (0x04 || root_addr || inner_sig) - Set credential source to root_account address (the wallet) This enables passkey-based Tempo wallets with access keys to use pympp. Previously root_account was declared but unused. Works with fee payer (0x78 envelope) — tested end-to-end against modal.mpp.tempo.xyz. Amp-Thread-ID: https://ampcode.com/threads/T-019d0db3-f0b0-73aa-9403-704f79d8d9f1 Co-authored-by: Amp --- src/mpp/methods/tempo/client.py | 21 ++++-- tests/test_tempo.py | 115 ++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 64c10da..2562baa 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -154,10 +154,14 @@ async def create_credential(self, challenge: Challenge) -> Credential: awaiting_fee_payer=use_fee_payer, ) + # When signing with an access key, the credential source is the + # root account (the smart wallet), not the access key. + source_address = self.root_account if self.root_account else self.account.address + return Credential( challenge=challenge.to_echo(), payload={"type": "transaction", "signature": raw_tx}, - source=f"did:pkh:eip155:{chain_id}:{self.account.address}", + source=f"did:pkh:eip155:{chain_id}:{source_address}", ) async def _build_tempo_transfer( @@ -209,8 +213,12 @@ async def _build_tempo_transfer( else: transfer_data = self._encode_transfer(recipient, int(amount)) + # When using an access key, fetch nonce from the root account + # (smart wallet), not the access key address. + nonce_address = self.root_account if self.root_account else self.account.address + chain_id, on_chain_nonce, gas_price = await get_tx_params( - resolved_rpc, self.account.address + resolved_rpc, nonce_address ) if expected_chain_id is not None and chain_id != expected_chain_id: @@ -231,7 +239,7 @@ async def _build_tempo_transfer( gas_limit = DEFAULT_GAS_LIMIT try: estimated = await estimate_gas( - resolved_rpc, self.account.address, currency, transfer_data + resolved_rpc, nonce_address, currency, transfer_data ) gas_limit = max(gas_limit, estimated + 5_000) except Exception: @@ -250,7 +258,12 @@ async def _build_tempo_transfer( calls=(Call.create(to=currency, value=0, data=transfer_data),), ) - signed_tx = tx.sign(self.account.private_key) + if self.root_account: + from pytempo import sign_tx_access_key + + signed_tx = sign_tx_access_key(tx, self.account.private_key, self.root_account) + else: + signed_tx = tx.sign(self.account.private_key) if awaiting_fee_payer: from mpp.methods.tempo.fee_payer_envelope import encode_fee_payer_envelope diff --git a/tests/test_tempo.py b/tests/test_tempo.py index 2dccdbf..479c602 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -1384,6 +1384,121 @@ async def test_client_chain_id_mismatch_raises(self, httpx_mock: HTTPXMock) -> N await method.create_credential(challenge) +class TestAccessKeySigning: + """Tests for access key (root_account) signing flow.""" + + @pytest.mark.asyncio + async def test_access_key_builds_keychain_signature(self, httpx_mock: HTTPXMock) -> None: + """When root_account is set, should use sign_tx_access_key.""" + access_key = TempoAccount.from_key(TEST_PRIVATE_KEY) + root = "0x975937feafc6869a260c176854dda8764a78e122" + method = tempo( + account=access_key, + root_account=root, + rpc_url="https://rpc.test", + intents={"charge": ChargeIntent()}, + ) + + # Mock RPC: chain_id, nonce, gas_price, estimateGas + for _ in range(4): + httpx_mock.add_response( + url="https://rpc.test", + json={"jsonrpc": "2.0", "result": "0x1", "id": 1}, + ) + + challenge = Challenge( + id="test-access-key", + method="tempo", + intent="charge", + request={ + "amount": "1000000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + }, + realm="test.example.com", + request_b64="e30", + ) + + credential = await method.create_credential(challenge) + + assert credential.payload["type"] == "transaction" + # source should be the root account, not the access key + assert root.lower() in credential.source.lower() + assert access_key.address.lower() not in credential.source.lower() + + @pytest.mark.asyncio + async def test_access_key_with_fee_payer(self, httpx_mock: HTTPXMock) -> None: + """Access key + feePayer should produce a 0x78 envelope with keychain sig.""" + access_key = TempoAccount.from_key(TEST_PRIVATE_KEY) + root = "0x975937feafc6869a260c176854dda8764a78e122" + method = tempo( + account=access_key, + root_account=root, + rpc_url="https://rpc.test", + intents={"charge": ChargeIntent()}, + ) + + for _ in range(4): + httpx_mock.add_response( + url="https://rpc.test", + json={"jsonrpc": "2.0", "result": "0x1", "id": 1}, + ) + + challenge = Challenge( + id="test-access-key-sponsored", + method="tempo", + intent="charge", + request={ + "amount": "1000000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + "methodDetails": {"feePayer": True, "chainId": 1}, + }, + realm="test.example.com", + request_b64="e30", + ) + + credential = await method.create_credential(challenge) + + assert credential.payload["type"] == "transaction" + assert credential.payload["signature"].startswith("0x78") + assert root.lower() in credential.source.lower() + + @pytest.mark.asyncio + async def test_no_root_account_uses_regular_signing(self, httpx_mock: HTTPXMock) -> None: + """Without root_account, should use regular tx.sign().""" + account = TempoAccount.from_key(TEST_PRIVATE_KEY) + method = tempo( + account=account, + rpc_url="https://rpc.test", + intents={"charge": ChargeIntent()}, + ) + + for _ in range(4): + httpx_mock.add_response( + url="https://rpc.test", + json={"jsonrpc": "2.0", "result": "0x1", "id": 1}, + ) + + challenge = Challenge( + id="test-no-root", + method="tempo", + intent="charge", + request={ + "amount": "1000000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + }, + realm="test.example.com", + request_b64="e30", + ) + + credential = await method.create_credential(challenge) + + assert credential.payload["type"] == "transaction" + assert account.address in credential.source + + class TestMatchTransferCalldataWithMemo: """Tests for _match_transfer_calldata with memo field.""" From 6fd8a2a59a9d5f4d1e2cbec3642dd091b55cec2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Mar 2026 20:27:47 +0000 Subject: [PATCH 2/2] chore: add changelog --- .changelog/dry-geese-growl.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/dry-geese-growl.md diff --git a/.changelog/dry-geese-growl.md b/.changelog/dry-geese-growl.md new file mode 100644 index 0000000..2f2a053 --- /dev/null +++ b/.changelog/dry-geese-growl.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added access key signing support for Tempo transactions. When `root_account` is set, nonce and gas estimation now use the root account (smart wallet) address, transactions are signed via `sign_tx_access_key`, and the credential source reflects the root account rather than the access key address.