Skip to content

Commit 838c2f5

Browse files
committed
eip712 signature requests for pydantic messages
1 parent eee2e7b commit 838c2f5

File tree

6 files changed

+272
-18
lines changed

6 files changed

+272
-18
lines changed

framelib/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
from .frame import frame, message, error
66
from .hub import validate_message, validate_message_or_mock
7-
from .models import FrameMessage, ValidatedMessage, User
7+
from .models import FrameMessage, ValidatedMessage, User, Address, Bytes, Bytes32
88
from .warpcast import get_user
99
from .neynar import (
1010
validate_message as validate_message_neynar,
1111
validate_message_or_mock as validate_message_or_mock_neynar
1212
)
13-
from .transaction import transaction, mint
13+
from .transaction import transaction, mint, signature

framelib/models.py

+52-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
import datetime
66
from typing import Optional, Literal
7-
from pydantic import BaseModel
7+
from pydantic import BaseModel, SerializeAsAny
8+
from eth_utils import is_address
89

910

1011
# ---- frame message ----
@@ -46,8 +47,8 @@ class FrameMessage(BaseModel):
4647
class EthTransactionParams(BaseModel):
4748
abi: list[dict]
4849
to: str
49-
value: Optional[str]
50-
data: Optional[str]
50+
value: Optional[str] = None
51+
data: Optional[str] = None
5152

5253

5354
class Transaction(BaseModel):
@@ -56,6 +57,54 @@ class Transaction(BaseModel):
5657
params: EthTransactionParams
5758

5859

60+
# ---- signature ----
61+
62+
class Address(str):
63+
@classmethod
64+
def __get_validators__(cls):
65+
yield cls.validate
66+
67+
@classmethod
68+
def validate(cls, value, info):
69+
if not is_address(value):
70+
raise ValueError('invalid ethereum address')
71+
return value
72+
73+
74+
class Bytes32(str):
75+
pass
76+
77+
78+
class Bytes(str):
79+
pass
80+
81+
82+
class Eip712Domain(BaseModel):
83+
name: Optional[str] = None
84+
version: Optional[str] = None
85+
chainId: Optional[int] = None
86+
verifyingContract: Optional[Address] = None
87+
salt: Optional[str] = None
88+
89+
90+
class Eip712TypeField(BaseModel):
91+
name: str
92+
type: str
93+
94+
95+
class Eip712Params(BaseModel):
96+
domain: Eip712Domain
97+
types: dict[str, list[Eip712TypeField]]
98+
primaryType: str
99+
message: SerializeAsAny[BaseModel]
100+
101+
102+
class Signature(BaseModel):
103+
chainId: str
104+
method: Literal['eth_signTypedData_v4']
105+
params: Eip712Params
106+
107+
59108
# ---- frame error ----
60109

61110
class FrameError(BaseModel):

framelib/transaction.py

+77-7
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,24 @@
33
"""
44

55
# lib
6+
from typing import Type
67
from eth_abi import encode
78
from eth_utils import is_address, function_signature_to_4byte_selector, function_abi_to_4byte_selector
89
from flask import jsonify, Response
10+
from pydantic import BaseModel
911

1012
# src
11-
from .models import Transaction, EthTransactionParams
13+
from .models import Transaction, EthTransactionParams, Address, Bytes, Bytes32, Eip712TypeField, Signature, \
14+
Eip712Domain, Eip712Params
1215

1316

1417
def transaction(
15-
chain_id: int,
16-
contract: str,
17-
abi: list[dict],
18-
value: str = None,
19-
function_signature: str = None,
20-
function_arguments: list = None
18+
chain_id: int,
19+
contract: str,
20+
abi: list[dict],
21+
value: str = None,
22+
function_signature: str = None,
23+
function_arguments: list = None
2124
) -> Response:
2225
if not is_address(contract):
2326
raise ValueError(f'invalid contract address {contract}')
@@ -62,3 +65,70 @@ def mint(chain_id: int, contract: str, token_id: int = None) -> str:
6265
target += f':{token_id}'
6366

6467
return target
68+
69+
70+
def signature(
71+
chain_id: int,
72+
message: BaseModel,
73+
domain: str = None,
74+
version: str = None,
75+
contract: str = None,
76+
salt: str = None
77+
) -> Response:
78+
# collect custom types
79+
def recurse_model_types(model: Type[BaseModel]):
80+
types_ = {}
81+
for name_, field_ in model.__annotations__.items():
82+
if not issubclass(field_, BaseModel):
83+
continue
84+
types_ = recurse_model_types(field_)
85+
types_[field_.__name__] = field_
86+
types_[model.__name__] = model
87+
return types_
88+
89+
types = recurse_model_types(message.__class__)
90+
91+
primitives = {
92+
'int': 'uint256',
93+
'str': 'string',
94+
'bool': 'bool',
95+
'Address': 'address',
96+
'Bytes': 'bytes',
97+
'Bytes32': 'bytes32'
98+
}
99+
100+
# format eip712 type definitions
101+
eip712_types = {}
102+
for name, cls in types.items():
103+
fields = []
104+
for n, f in cls.__annotations__.items():
105+
t = f.__name__
106+
if t in primitives:
107+
fields.append(Eip712TypeField(name=n, type=primitives[t]))
108+
elif t in types:
109+
fields.append(Eip712TypeField(name=n, type=t))
110+
else:
111+
raise ValueError(f'unsupported field type {n} {t}')
112+
eip712_types[name] = fields
113+
114+
sig = Signature(
115+
chainId=f'eip155:{chain_id}',
116+
method='eth_signTypedData_v4',
117+
params=Eip712Params(
118+
domain=Eip712Domain(
119+
name=domain,
120+
version=version,
121+
chainId=chain_id,
122+
verifyingContract=contract,
123+
salt=salt
124+
),
125+
types=eip712_types,
126+
primaryType=message.__class__.__name__,
127+
message=message
128+
)
129+
)
130+
131+
# response
132+
res = jsonify(sig.model_dump(mode='json', exclude_none=True))
133+
res.status_code = 200
134+
return res

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setuptools.setup(
77
name='framelib',
8-
version='0.0.6',
8+
version='0.0.7b0',
99
author='Devin A. Conley',
1010
author_email='[email protected]',
1111
description='lightweight library for building farcaster frames using python and flask',

test/test_mint.py

-5
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
test cases for frame minting logic
33
"""
44

5-
# lib
6-
import json
7-
import pytest
8-
from flask import Flask
9-
105
# src
116
from framelib import mint
127

test/test_signature.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
test cases for frame signature requests and verification
3+
"""
4+
5+
# lib
6+
from pydantic import BaseModel
7+
from flask import Flask
8+
9+
# src
10+
from framelib import signature, Address
11+
12+
app = Flask(__name__)
13+
14+
15+
class TestSignature(object):
16+
17+
def test_signature_request(self):
18+
class Message(BaseModel):
19+
message: str
20+
timestamp: int
21+
22+
msg = Message(message='hello world', timestamp=1234567890)
23+
24+
with app.app_context():
25+
res = signature(1, msg, domain='myprotocol')
26+
27+
assert res.status_code == 200
28+
assert res.json['chainId'] == 'eip155:1'
29+
assert res.json['method'] == 'eth_signTypedData_v4'
30+
assert res.json['params']['domain']['name'] == 'myprotocol'
31+
assert res.json['params']['domain']['chainId'] == 1
32+
assert 'salt' not in res.json['params']['domain']
33+
assert 'verifyingContract' not in res.json['params']['domain']
34+
35+
assert len(res.json['params']['types']) == 1
36+
assert res.json['params']['types']['Message'] == [
37+
{'name': 'message', 'type': 'string'},
38+
{'name': 'timestamp', 'type': 'uint256'}
39+
]
40+
assert res.json['params']['primaryType'] == 'Message'
41+
assert res.json['params']['message'] == {'message': 'hello world', 'timestamp': 1234567890}
42+
43+
def test_signature_request_nested(self):
44+
class User(BaseModel):
45+
name: str
46+
47+
class Message(BaseModel):
48+
message: str
49+
timestamp: int
50+
sender: User
51+
recipient: User
52+
53+
msg = Message(
54+
message='hello bob',
55+
timestamp=1234567890,
56+
sender=User(name='alice'),
57+
recipient=User(name='bob')
58+
)
59+
60+
with app.app_context():
61+
res = signature(
62+
8453,
63+
msg,
64+
domain='another_app',
65+
version='v3',
66+
contract='0x1234567890abcdef1234567890abcdef12345678',
67+
salt='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
68+
)
69+
70+
assert res.status_code == 200
71+
assert res.json['chainId'] == 'eip155:8453'
72+
assert res.json['method'] == 'eth_signTypedData_v4'
73+
assert res.json['params']['domain']['name'] == 'another_app'
74+
assert res.json['params']['domain']['version'] == 'v3'
75+
assert res.json['params']['domain']['chainId'] == 8453
76+
assert res.json['params']['domain']['verifyingContract'] \
77+
== '0x1234567890abcdef1234567890abcdef12345678'
78+
assert res.json['params']['domain']['salt'] \
79+
== '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef'
80+
81+
assert len(res.json['params']['types']) == 2
82+
assert res.json['params']['types']['Message'] == [
83+
{'name': 'message', 'type': 'string'},
84+
{'name': 'timestamp', 'type': 'uint256'},
85+
{'name': 'sender', 'type': 'User'},
86+
{'name': 'recipient', 'type': 'User'}
87+
]
88+
assert res.json['params']['types']['User'] == [
89+
{'name': 'name', 'type': 'string'}
90+
]
91+
assert res.json['params']['primaryType'] == 'Message'
92+
assert res.json['params']['message'] == {
93+
'message': 'hello bob',
94+
'timestamp': 1234567890,
95+
'sender': {'name': 'alice'},
96+
'recipient': {'name': 'bob'}
97+
}
98+
99+
def test_signature_request_eth(self):
100+
class Approval(BaseModel):
101+
token: Address
102+
limit: int
103+
expiry: int
104+
105+
msg = Approval(
106+
token='0x4200000000000000000000000000000000000006',
107+
limit=int(500e18),
108+
expiry=1234567890
109+
)
110+
111+
with app.app_context():
112+
res = signature(
113+
8453,
114+
msg,
115+
domain='gasless_exchange',
116+
contract='0x1234567890abcdef1234567890abcdef12345678',
117+
)
118+
119+
assert res.status_code == 200
120+
assert res.json['chainId'] == 'eip155:8453'
121+
assert res.json['method'] == 'eth_signTypedData_v4'
122+
assert res.json['params']['domain']['name'] == 'gasless_exchange'
123+
assert res.json['params']['domain']['chainId'] == 8453
124+
assert res.json['params']['domain']['verifyingContract'] \
125+
== '0x1234567890abcdef1234567890abcdef12345678'
126+
assert 'salt' not in res.json['params']['domain']
127+
assert 'version' not in res.json['params']['domain']
128+
129+
assert len(res.json['params']['types']) == 1
130+
assert res.json['params']['types']['Approval'] == [
131+
{'name': 'token', 'type': 'address'},
132+
{'name': 'limit', 'type': 'uint256'},
133+
{'name': 'expiry', 'type': 'uint256'}
134+
]
135+
assert res.json['params']['primaryType'] == 'Approval'
136+
assert res.json['params']['message'] == {
137+
'token': '0x4200000000000000000000000000000000000006',
138+
'limit': 500e18,
139+
'expiry': 1234567890
140+
}

0 commit comments

Comments
 (0)