Skip to content

Commit fb57ed4

Browse files
nielstroncffls
andauthored
Automatically reduce 0 and empty multiassets (#372)
* Add tests where we try to submit empty or zero multi-assets * Add tests for empty multiassets * Fix min_utxo could be none * Automatically reduce multiassets and assets * Formatting * Fix failing test due to now empty multiasset * Formatting * A bit more comprehensive on testing * formatting * Remove policy id where it is not needed * Reduce code duplication, include normalization for any serialization * Fix * Remove redundant line * Fix fee too low User can potentially provide signing keys that are not required by the finalized transaction. For example, if a transaction include a minting script and signing key for the script, but the minted value is 0, the signature of the minting script isn't required. This commit will auto detect such cases and skip the signing of this key. * Fix test --------- Co-authored-by: Jerry <[email protected]>
1 parent 7243cd9 commit fb57ed4

File tree

7 files changed

+369
-9
lines changed

7 files changed

+369
-9
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import pathlib
2+
import tempfile
3+
4+
import pytest
5+
from retry import retry
6+
7+
from pycardano import *
8+
9+
from .base import TEST_RETRIES, TestBase
10+
11+
12+
class TestZeroEmptyAsset(TestBase):
13+
@retry(tries=TEST_RETRIES, backoff=1.5, delay=6, jitter=(0, 4))
14+
@pytest.mark.post_chang
15+
def test_submit_zero_and_empty(self):
16+
address = Address(self.payment_vkey.hash(), network=self.NETWORK)
17+
18+
# Load payment keys or create them if they don't exist
19+
def load_or_create_key_pair(base_dir, base_name):
20+
skey_path = base_dir / f"{base_name}.skey"
21+
vkey_path = base_dir / f"{base_name}.vkey"
22+
23+
if skey_path.exists():
24+
skey = PaymentSigningKey.load(str(skey_path))
25+
vkey = PaymentVerificationKey.from_signing_key(skey)
26+
else:
27+
key_pair = PaymentKeyPair.generate()
28+
key_pair.signing_key.save(str(skey_path))
29+
key_pair.verification_key.save(str(vkey_path))
30+
skey = key_pair.signing_key
31+
vkey = key_pair.verification_key
32+
return skey, vkey
33+
34+
tempdir = tempfile.TemporaryDirectory()
35+
PROJECT_ROOT = tempdir.name
36+
37+
root = pathlib.Path(PROJECT_ROOT)
38+
# Create the directory if it doesn't exist
39+
root.mkdir(parents=True, exist_ok=True)
40+
"""Generate keys"""
41+
key_dir = root / "keys"
42+
key_dir.mkdir(exist_ok=True)
43+
44+
# Generate policy keys, which will be used when minting NFT
45+
policy_skey, policy_vkey = load_or_create_key_pair(key_dir, "policy")
46+
47+
"""Create policy"""
48+
# A policy that requires a signature from the policy key we generated above
49+
pub_key_policy_1 = ScriptPubkey(policy_vkey.hash())
50+
51+
# A policy that requires a signature from the extended payment key
52+
pub_key_policy_2 = ScriptPubkey(self.extended_payment_vkey.hash())
53+
54+
# Combine two policies using ScriptAll policy
55+
policy = ScriptAll([pub_key_policy_1, pub_key_policy_2])
56+
57+
# Calculate policy ID, which is the hash of the policy
58+
policy_id = policy.hash()
59+
60+
"""Define NFT"""
61+
my_nft = MultiAsset.from_primitive(
62+
{
63+
policy_id.payload: {
64+
b"MY_NFT_1": 1, # Name of our NFT1 # Quantity of this NFT
65+
b"MY_NFT_2": 1, # Name of our NFT2 # Quantity of this NFT
66+
}
67+
}
68+
)
69+
70+
native_scripts = [policy]
71+
72+
"""Create metadata"""
73+
# We need to create a metadata for our NFTs, so they could be displayed correctly by blockchain explorer
74+
metadata = {
75+
721: { # 721 refers to the metadata label registered for NFT standard here:
76+
# https://github.com/cardano-foundation/CIPs/blob/master/CIP-0010/registry.json#L14-L17
77+
policy_id.payload.hex(): {
78+
"MY_NFT_1": {
79+
"description": "This is my first NFT thanks to PyCardano",
80+
"name": "PyCardano NFT example token 1",
81+
"id": 1,
82+
"image": "ipfs://QmRhTTbUrPYEw3mJGGhQqQST9k86v1DPBiTTWJGKDJsVFw",
83+
},
84+
"MY_NFT_2": {
85+
"description": "This is my second NFT thanks to PyCardano",
86+
"name": "PyCardano NFT example token 2",
87+
"id": 2,
88+
"image": "ipfs://QmRhTTbUrPYEw3mJGGhQqQST9k86v1DPBiTTWJGKDJsVFw",
89+
},
90+
}
91+
}
92+
}
93+
94+
# Place metadata in AuxiliaryData, the format acceptable by a transaction.
95+
auxiliary_data = AuxiliaryData(AlonzoMetadata(metadata=Metadata(metadata)))
96+
97+
"""Build mint transaction"""
98+
99+
# Create a transaction builder
100+
builder = TransactionBuilder(self.chain_context)
101+
102+
# Add our own address as the input address
103+
builder.add_input_address(address)
104+
105+
# Set nft we want to mint
106+
builder.mint = my_nft
107+
108+
# Set native script
109+
builder.native_scripts = native_scripts
110+
111+
# Set transaction metadata
112+
builder.auxiliary_data = auxiliary_data
113+
114+
# Calculate the minimum amount of lovelace that need to hold the NFT we are going to mint
115+
min_val = min_lovelace_pre_alonzo(Value(0, my_nft), self.chain_context)
116+
117+
# Send the NFT to our own address
118+
nft_output = TransactionOutput(address, Value(min_val, my_nft))
119+
builder.add_output(nft_output)
120+
121+
# Build and sign transaction
122+
signed_tx = builder.build_and_sign(
123+
[self.payment_skey, self.extended_payment_skey, policy_skey], address
124+
)
125+
126+
print("############### Transaction created ###############")
127+
print(signed_tx)
128+
print(signed_tx.to_cbor_hex())
129+
130+
# Submit signed transaction to the network
131+
print("############### Submitting transaction ###############")
132+
self.chain_context.submit_tx(signed_tx)
133+
134+
self.assert_output(address, nft_output)
135+
136+
"""Build transaction with 0 nft"""
137+
138+
# Create a transaction builder
139+
builder = TransactionBuilder(self.chain_context)
140+
141+
# Add our own address as the input address
142+
builder.add_input_address(address)
143+
144+
# Calculate the minimum amount of lovelace that need to hold the NFT we are going to mint
145+
min_val = min_lovelace_pre_alonzo(Value(0), self.chain_context)
146+
147+
# Send the NFT to our own address
148+
nft_output = TransactionOutput(
149+
address,
150+
Value(
151+
min_val,
152+
MultiAsset.from_primitive(
153+
{policy_vkey.hash().payload: {b"MY_NFT_1": 0}}
154+
),
155+
),
156+
)
157+
builder.add_output(nft_output)
158+
159+
# Build and sign transaction
160+
signed_tx = builder.build_and_sign(
161+
[self.payment_skey, self.extended_payment_skey], address
162+
)
163+
164+
print("############### Transaction created ###############")
165+
print(signed_tx)
166+
print(signed_tx.to_cbor_hex())
167+
168+
# Submit signed transaction to the network
169+
print("############### Submitting transaction ###############")
170+
self.chain_context.submit_tx(signed_tx)
171+
172+
self.assert_output(address, nft_output)
173+
174+
"""Build transaction with empty multi-asset"""
175+
176+
# Create a transaction builder
177+
builder = TransactionBuilder(self.chain_context)
178+
179+
# Add our own address as the input address
180+
builder.add_input_address(address)
181+
182+
# Calculate the minimum amount of lovelace that need to hold the NFT we are going to mint
183+
min_val = min_lovelace_pre_alonzo(Value(0), self.chain_context)
184+
185+
# Send the NFT to our own address
186+
nft_output = TransactionOutput(
187+
address,
188+
Value(min_val, MultiAsset.from_primitive({policy_vkey.hash().payload: {}})),
189+
)
190+
builder.add_output(nft_output)
191+
192+
# Build and sign transaction
193+
signed_tx = builder.build_and_sign(
194+
[self.payment_skey, self.extended_payment_skey], address
195+
)
196+
197+
print("############### Transaction created ###############")
198+
print(signed_tx)
199+
print(signed_tx.to_cbor_hex())
200+
201+
# Submit signed transaction to the network
202+
print("############### Submitting transaction ###############")
203+
self.chain_context.submit_tx(signed_tx)
204+
205+
self.assert_output(address, nft_output)

pycardano/transaction.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@
3838
from pycardano.serialization import (
3939
ArrayCBORSerializable,
4040
CBORSerializable,
41+
DictBase,
4142
DictCBORSerializable,
4243
MapCBORSerializable,
4344
Primitive,
4445
default_encoder,
46+
limit_primitive_type,
4547
list_hook,
4648
)
4749
from pycardano.types import typechecked
@@ -87,25 +89,32 @@ class Asset(DictCBORSerializable):
8789

8890
VALUE_TYPE = int
8991

92+
def normalize(self) -> Asset:
93+
"""Normalize the Asset by removing zero values."""
94+
for k, v in list(self.items()):
95+
if v == 0:
96+
self.pop(k)
97+
return self
98+
9099
def union(self, other: Asset) -> Asset:
91100
return self + other
92101

93102
def __add__(self, other: Asset) -> Asset:
94103
new_asset = deepcopy(self)
95104
for n in other:
96105
new_asset[n] = new_asset.get(n, 0) + other[n]
97-
return new_asset
106+
return new_asset.normalize()
98107

99108
def __iadd__(self, other: Asset) -> Asset:
100109
new_item = self + other
101110
self.update(new_item)
102-
return self
111+
return self.normalize()
103112

104113
def __sub__(self, other: Asset) -> Asset:
105114
new_asset = deepcopy(self)
106115
for n in other:
107116
new_asset[n] = new_asset.get(n, 0) - other[n]
108-
return new_asset
117+
return new_asset.normalize()
109118

110119
def __eq__(self, other):
111120
if not isinstance(other, Asset):
@@ -124,6 +133,20 @@ def __le__(self, other: Asset) -> bool:
124133
return False
125134
return True
126135

136+
@classmethod
137+
@limit_primitive_type(dict)
138+
def from_primitive(cls: Type[DictBase], value: dict) -> DictBase:
139+
res = super().from_primitive(value)
140+
# pop zero values
141+
for n, v in list(res.items()):
142+
if v == 0:
143+
res.pop(n)
144+
return res
145+
146+
def to_shallow_primitive(self) -> dict:
147+
x = deepcopy(self).normalize()
148+
return super(self.__class__, x).to_shallow_primitive()
149+
127150

128151
@typechecked
129152
class MultiAsset(DictCBORSerializable):
@@ -134,22 +157,30 @@ class MultiAsset(DictCBORSerializable):
134157
def union(self, other: MultiAsset) -> MultiAsset:
135158
return self + other
136159

160+
def normalize(self) -> MultiAsset:
161+
"""Normalize the MultiAsset by removing zero values."""
162+
for k, v in list(self.items()):
163+
v.normalize()
164+
if len(v) == 0:
165+
self.pop(k)
166+
return self
167+
137168
def __add__(self, other):
138169
new_multi_asset = deepcopy(self)
139170
for p in other:
140171
new_multi_asset[p] = new_multi_asset.get(p, Asset()) + other[p]
141-
return new_multi_asset
172+
return new_multi_asset.normalize()
142173

143174
def __iadd__(self, other):
144175
new_item = self + other
145176
self.update(new_item)
146-
return self
177+
return self.normalize()
147178

148179
def __sub__(self, other: MultiAsset) -> MultiAsset:
149180
new_multi_asset = deepcopy(self)
150181
for p in other:
151182
new_multi_asset[p] = new_multi_asset.get(p, Asset()) - other[p]
152-
return new_multi_asset
183+
return new_multi_asset.normalize()
153184

154185
def __eq__(self, other):
155186
if not isinstance(other, MultiAsset):
@@ -209,6 +240,20 @@ def count(self, criteria=Callable[[ScriptHash, AssetName, int], bool]) -> int:
209240

210241
return count
211242

243+
@classmethod
244+
@limit_primitive_type(dict)
245+
def from_primitive(cls: Type[DictBase], value: dict) -> DictBase:
246+
res = super().from_primitive(value)
247+
# pop empty values
248+
for n, v in list(res.items()):
249+
if not v:
250+
res.pop(n)
251+
return res
252+
253+
def to_shallow_primitive(self) -> dict:
254+
x = deepcopy(self).normalize()
255+
return super(self.__class__, x).to_shallow_primitive()
256+
212257

213258
@typechecked
214259
@dataclass(repr=False)

pycardano/txbuilder.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,12 +928,16 @@ def _build_tx_body(self) -> TransactionBody:
928928
)
929929
return tx_body
930930

931-
def _build_fake_vkey_witnesses(self) -> List[VerificationKeyWitness]:
931+
def _build_required_vkeys(self) -> Set[VerificationKeyHash]:
932932
vkey_hashes = self._input_vkey_hashes()
933933
vkey_hashes.update(self._required_signer_vkey_hashes())
934934
vkey_hashes.update(self._native_scripts_vkey_hashes())
935935
vkey_hashes.update(self._certificate_vkey_hashes())
936936
vkey_hashes.update(self._withdrawal_vkey_hashes())
937+
return vkey_hashes
938+
939+
def _build_fake_vkey_witnesses(self) -> List[VerificationKeyWitness]:
940+
vkey_hashes = self._build_required_vkeys()
937941

938942
witness_count = self.witness_override or len(vkey_hashes)
939943

@@ -1441,6 +1445,7 @@ def build_and_sign(
14411445
auto_validity_start_offset: Optional[int] = None,
14421446
auto_ttl_offset: Optional[int] = None,
14431447
auto_required_signers: Optional[bool] = None,
1448+
force_skeys: Optional[bool] = False,
14441449
) -> Transaction:
14451450
"""Build a transaction body from all constraints set through the builder and sign the transaction with
14461451
provided signing keys.
@@ -1462,6 +1467,10 @@ def build_and_sign(
14621467
auto_required_signers (Optional[bool]): Automatically add all pubkeyhashes of transaction inputs
14631468
and the given signers to required signatories (default only for Smart Contract transactions).
14641469
Manually set required signers will always take precedence.
1470+
force_skeys (Optional[bool]): Whether to force the use of signing keys for signing the transaction.
1471+
Default is False, which means that provided signing keys will only be used to sign the transaction if
1472+
they are actually required by the transaction. This is useful to reduce tx fees by not including
1473+
unnecessary signatures. If set to True, all provided signing keys will be used to sign the transaction.
14651474
14661475
Returns:
14671476
Transaction: A signed transaction.
@@ -1483,7 +1492,15 @@ def build_and_sign(
14831492
witness_set = self.build_witness_set(True)
14841493
witness_set.vkey_witnesses = []
14851494

1495+
required_vkeys = self._build_required_vkeys()
1496+
14861497
for signing_key in set(signing_keys):
1498+
vkey_hash = signing_key.to_verification_key().hash()
1499+
if not force_skeys and vkey_hash not in required_vkeys:
1500+
logger.warn(
1501+
f"Verification key hash {vkey_hash} is not required for this tx."
1502+
)
1503+
continue
14871504
signature = signing_key.sign(tx_body.hash())
14881505
witness_set.vkey_witnesses.append(
14891506
VerificationKeyWitness(signing_key.to_verification_key(), signature)

pycardano/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def min_lovelace_pre_alonzo(
184184
int: Minimum required lovelace amount for this transaction output.
185185
"""
186186
if amount is None or isinstance(amount, int) or not amount.multi_asset:
187-
return context.protocol_param.min_utxo
187+
return context.protocol_param.min_utxo or 1_000_000
188188

189189
b_size = bundle_size(amount.multi_asset)
190190
utxo_entry_size = 27

0 commit comments

Comments
 (0)