Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/dry-geese-growl.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 17 additions & 4 deletions src/mpp/methods/tempo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/test_tempo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down