Skip to content

Commit 09706e8

Browse files
pnowosiekonichuvak
and
konichuvak
authored
feat: Add Authenticator flow for Permissioned Keys (#330)
This is cleaned & fixed version of #328 Big thanks to the original contributor: @konichuvak --------- Co-authored-by: konichuvak <[email protected]>
1 parent 38d9134 commit 09706e8

14 files changed

+1529
-859
lines changed

Diff for: v4-client-py-v2/dydx_v4_client/config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
GAS_MULTIPLIER = 1.4
1+
GAS_MULTIPLIER = 1.8

Diff for: v4-client-py-v2/dydx_v4_client/network.py

-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ def make_config(
4646
make_secure = partial(make_config, secure_channel)
4747
make_insecure = partial(make_config, insecure_channel)
4848

49-
5049
mainnet_node = partial(
5150
NodeConfig,
5251
"dydx-mainnet-1",
@@ -73,7 +72,6 @@ def make_config(
7372
TESTNET_FAUCET = "https://faucet.v4testnet.dydx.exchange"
7473
TESTNET_NOBLE = "https://rpc.testnet.noble.strange.love"
7574

76-
7775
local_node = partial(
7876
NodeConfig,
7977
"localdydxprotocol",
+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from enum import Enum
2+
import json
3+
from dataclasses import asdict, dataclass
4+
from typing import List
5+
6+
7+
class AuthenticatorType(str, Enum):
8+
AllOf = "AllOf"
9+
AnyOf = "AnyOf"
10+
SignatureVerification = "SignatureVerification"
11+
MessageFilter = "MessageFilter"
12+
SubaccountFilter = "SubaccountFilter"
13+
ClobPairIdFilter = "ClobPairIdFilter"
14+
15+
16+
@dataclass
17+
class Authenticator:
18+
type: AuthenticatorType
19+
config: bytes
20+
21+
# helpers to create Authenticator instances
22+
@classmethod
23+
def signature_verification(cls, pub_key: bytes) -> "Authenticator":
24+
"""Enables authentication via a specific key."""
25+
return Authenticator(
26+
AuthenticatorType.SignatureVerification,
27+
pub_key,
28+
)
29+
30+
@classmethod
31+
def message_filter(cls, msg_type: str) -> "Authenticator":
32+
"""Restricts authentication to certain message types."""
33+
return Authenticator(
34+
AuthenticatorType.MessageFilter,
35+
msg_type.encode(),
36+
)
37+
38+
@classmethod
39+
def subaccount_filter(cls, subaccounts: List[int]) -> "Authenticator":
40+
"""Restricts authentication to a specific subaccount."""
41+
config = ",".join(map(str, subaccounts))
42+
return Authenticator(
43+
AuthenticatorType.SubaccountFilter,
44+
config.encode(),
45+
)
46+
47+
@classmethod
48+
def clob_pair_id_filter(cls, clob_pair_ids: List[int]) -> "Authenticator":
49+
"""Restricts authentication to a specific clob pair id."""
50+
config = ",".join(map(str, clob_pair_ids))
51+
return Authenticator(
52+
AuthenticatorType.ClobPairIdFilter,
53+
config.encode(),
54+
)
55+
56+
@classmethod
57+
def compose(
58+
cls, auth_type: AuthenticatorType, sub_authenticators: list["Authenticator"]
59+
) -> "Authenticator":
60+
"""Combines multiple sub-authenticators into a single one."""
61+
composed_config = json.dumps(
62+
[sa.todict() for sa in sub_authenticators],
63+
separators=(",", ":"),
64+
)
65+
return Authenticator(
66+
auth_type,
67+
composed_config.encode(),
68+
)
69+
70+
def todict(self) -> dict:
71+
"""Prepare object for composition."""
72+
dicls = asdict(self)
73+
dicls["config"] = list(dicls["config"])
74+
return dicls
75+
76+
77+
def validate_authenticator(authenticator: Authenticator) -> bool:
78+
"""Validate the authenticator."""
79+
if authenticator.config.startswith(b"["):
80+
decoded_config = json.loads(authenticator.config.decode())
81+
else:
82+
decoded_config = authenticator.config
83+
84+
return check_authenticator(dict(type=authenticator.type, config=decoded_config))
85+
86+
87+
def check_authenticator(auth: dict) -> bool:
88+
"""
89+
Check if the authenticator is safe to use.
90+
Parameters:
91+
- auth is a decoded authenticator object.
92+
"""
93+
if not is_authenticator_alike(auth):
94+
return False
95+
96+
if auth["type"] == AuthenticatorType.SignatureVerification:
97+
# SignatureVerification authenticator is considered safe
98+
return True
99+
100+
if not isinstance(auth["config"], list):
101+
return False
102+
103+
if auth["type"] == AuthenticatorType.AnyOf:
104+
# ANY_OF is safe only if ALL sub-authenticators return true
105+
return all(check_authenticator(sub_auth) for sub_auth in auth["config"])
106+
107+
if auth["type"] == AuthenticatorType.AllOf:
108+
# ALL_OF is safe if at least one sub-authenticator returns true
109+
return any(check_authenticator(sub_auth) for sub_auth in auth["config"])
110+
111+
# If it's a base-case authenticator but not SignatureVerification, it's unsafe
112+
return False
113+
114+
115+
def is_authenticator_alike(auth: dict) -> bool:
116+
return isinstance(auth, dict) and auth.get("type") and auth.get("config")

Diff for: v4-client-py-v2/dydx_v4_client/node/builder.py

+46-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass
2-
from typing import List
2+
from typing import List, Optional
33

44
import google
55
from google.protobuf.message import Message
@@ -17,6 +17,7 @@
1717

1818
from dydx_v4_client.node.fee import calculate_fee, Denom
1919
from dydx_v4_client.wallet import Wallet
20+
from v4_proto.dydxprotocol.accountplus.tx_pb2 import TxExtension
2021

2122

2223
def as_any(message: Message):
@@ -50,6 +51,13 @@ def get_signature(key_pair, body, auth_info, account_number, chain_id):
5051
)
5152

5253

54+
@dataclass
55+
class TxOptions:
56+
authenticators: List[int]
57+
sequence: int
58+
account_number: int
59+
60+
5361
@dataclass
5462
class Builder:
5563
chain_id: str
@@ -69,17 +77,48 @@ def fee(self, gas_limit: int, *amount: List[Coin]) -> Fee:
6977
gas_limit=gas_limit,
7078
)
7179

72-
def build_transaction(self, wallet: Wallet, messages: List[Message], fee: Fee):
73-
body = TxBody(messages=messages, memo=self.memo)
80+
def build_transaction(
81+
self,
82+
wallet: Wallet,
83+
messages: List[Message],
84+
fee: Fee,
85+
tx_options: Optional[TxOptions] = None,
86+
) -> Tx:
87+
non_critical_extension_options = []
88+
if tx_options is not None:
89+
tx_extension = TxExtension(
90+
selected_authenticators=tx_options.authenticators,
91+
)
92+
non_critical_extension_options.append(as_any(tx_extension))
93+
body = TxBody(
94+
messages=messages,
95+
memo=self.memo,
96+
non_critical_extension_options=non_critical_extension_options,
97+
)
7498
auth_info = AuthInfo(
75-
signer_infos=[get_signer_info(wallet.public_key, wallet.sequence)],
99+
signer_infos=[
100+
get_signer_info(
101+
wallet.public_key,
102+
tx_options.sequence if tx_options else wallet.sequence,
103+
)
104+
],
76105
fee=fee,
77106
)
78107
signature = get_signature(
79-
wallet.key, body, auth_info, wallet.account_number, self.chain_id
108+
wallet.key,
109+
body,
110+
auth_info,
111+
tx_options.account_number if tx_options else wallet.account_number,
112+
self.chain_id,
80113
)
81114

82115
return Tx(body=body, auth_info=auth_info, signatures=[signature])
83116

84-
def build(self, wallet: Wallet, message: Message, fee: Fee = DEFAULT_FEE):
85-
return self.build_transaction(wallet, [as_any(message)], fee)
117+
def build(
118+
self,
119+
wallet: Wallet,
120+
message: Message,
121+
fee: Fee = DEFAULT_FEE,
122+
tx_options: Optional[dict] = None,
123+
) -> Tx:
124+
return self.build_transaction(wallet, [as_any(message)], fee, tx_options)

0 commit comments

Comments
 (0)