Sample DeFi API approvals
The article contains a few sample codes for DeFi API approvals.
Signing an Intent for EVM
Permit2 lets the user lock tokens and generate a signature to authorize a swap. Tokens stay secure until the swap is executed, either on the same chain or across chains. The resolver uses this signature to move funds into escrow and complete the swap.
Script to create a Permit2 signature
from eth_account import Account
from eth_account.messages import encode_typed_data
def execute_evm_permit2_swap(
client: ChangellyClient,
private_key: str,
sender_address: str,
from_token_id: int,
to_token_id: int,
amount: int,
slippage_bps: int,
receiver_address: str,
) -> None:
"""End-to-end EVM swap with Permit2 approval, showing signed_data construction."""
# ------------------------------------------------------------------
# 1. QUOTE — client call
# ------------------------------------------------------------------
quote: Quote = client.create_quote(
from_token_id=from_token_id,
to_token_id=to_token_id,
amount=amount,
slippage_bps=slippage_bps,
)
# ------------------------------------------------------------------
# 2. INTENT — client call
# ------------------------------------------------------------------
intent: Intent = client.create_intent(
quote_id=quote.id,
address_from=sender_address,
address_to=receiver_address,
refund_address=sender_address,
)
# intent.approval_type == ApprovalType.permit2
# intent.params_to_sign == provider-supplied EIP-712 signing parameters
# intent.expires_at == deadline epoch seconds
# ------------------------------------------------------------------
# 3. SIGNED_DATA — EVM Permit2 step by step
# ------------------------------------------------------------------
# 3a. Extract Permit2 envelope from the intent's signing parameters
permit2_envelope: dict = intent.params_to_sign # raw provider payload
escrow_address: str = permit2_envelope["escrow_contract_address"]
nonce: int = permit2_envelope["nonce"]
permit2_data: dict = permit2_envelope["additional_data"]
deadline: int = intent.expires_at
# 3b. Build canonical EIP-712 domain (Permit2 omits "version")
provider_domain: dict = permit2_data["domain"]
domain: dict[str, str | int] = {
field: provider_domain[field]
for field in ("name", "chainId", "verifyingContract")
if field in provider_domain
}
# 3c. Normalise witness hex integers to decimal strings
witness: dict = dict(permit2_data["witness"])
for int_field in ("minAmountOut", "maxAmountOut", "deadline"):
if raw := witness.get(int_field):
witness[int_field] = str(int(raw, 16)) # hex → decimal str
# 3d. Assemble the full EIP-712 PermitWitnessTransferFrom message
typed_data: dict = {
"domain": domain,
"types": permit2_data["types"],
"primaryType": "PermitWitnessTransferFrom",
"message": {
"permitted": {
"token": quote.from_.contract_address,
"amount": int(quote.from_.amount),
},
"spender": escrow_address,
"nonce": nonce,
"deadline": deadline,
"witness": witness,
},
}
# 3e. ECDSA sign with the local EVM private key
account = Account.from_key(private_key)
signed = account.sign_message(encode_typed_data(full_message=typed_data))
signed_data: str = f"0x{signed.signature.hex()}" # "0x" + 65-byte hex
# ------------------------------------------------------------------
# 4. APPROVE — submit the signed Permit2 payload
# ------------------------------------------------------------------
client.approve_intent(
intent_id=intent.id,
approval_type=ApprovalType.permit2,
signed_data=signed_data,
)
Signing an Intent for Tron
Permit2 on Tron lets the user lock tokens and generate a signature to authorize a swap. Tokens remain secure until the resolver executes the swap, either on Tron or across chains, using the signature to move funds into escrow.
Script to create a Permit2 signature
def _normalize_tron_address(tron_address: str) -> str:
#Convert a Tron address to canonical 0x-prefixed 20-byte hex.
#
#All three Tron address formats are accepted:
# base58check: TAUN6FwrnwwmaEqYcckffC7wBkXRk5cJhT
# 41-hex: 41a6d76276ae3c5d8e7c8c1f2e3a4b5c6d7e8f9a0b
# already hex: 0xa6d76276ae3c5d8e7c8c1f2e3a4b5c6d7e8f9a0b
#
#All three produce the same output: 0xa6d76276ae...
_BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
_PREFIX = b"\x41"
stripped = tron_address.removeprefix("0x")
# Already 20-byte hex — just re-add the 0x prefix
if len(stripped) == 40:
return f"0x{stripped.lower()}"
# Tron 41-prefixed hex — strip the network prefix
if len(stripped) == 42 and stripped[:2].lower() == "41":
return f"0x{stripped[2:].lower()}"
# Base58check: decode → strip version byte (0x41) → 20-byte hex
number = 0
for ch in tron_address:
number = number * 58 + _BASE58.index(ch)
decoded = number.to_bytes((number.bit_length() + 7) // 8, "big")
decoded = b"\x00" * (len(tron_address) - len(tron_address.lstrip("1"))) + decoded
payload = decoded[:-4] # strip 4-byte checksum
if len(payload) != 21 or not payload.startswith(_PREFIX):
raise ValueError(f"Invalid Tron address: {tron_address}")
return f"0x{payload[1:].hex()}" # strip the 0x41 prefix byte
def _normalize_tron_typed_data(typed_data: dict) -> dict:
"""Recursively convert all Tron address fields in EIP-712 typed data.
Without this step the Tron base58check or 41-hex addresses would be
encoded as raw strings during signing, producing a signature that the
provider contract cannot verify. After normalisation every address
is the canonical 0x-prefixed 20-byte hex that EIP-712 expects.
"""
normalized = copy.deepcopy(typed_data)
types = normalized["types"]
# Normalise verifyingContract in the domain separator
domain = normalized.get("domain", {})
if verifying_contract := domain.get("verifyingContract"):
domain["verifyingContract"] = _normalize_tron_address(str(verifying_contract))
# Recursively walk the message tree, normalising every "address" field
def _walk(type_name: str, value: dict) -> dict:
result = dict(value)
for field in types.get(type_name, []):
name = field["name"]
field_type = field["type"]
if name not in result:
continue
if field_type == "address":
result[name] = _normalize_tron_address(str(result[name]))
elif field_type in types and isinstance(result[name], dict):
result[name] = _walk(field_type, result[name])
return result
normalized["message"] = _walk(normalized["primaryType"], normalized["message"])
return normalized
# ------------------------------------------------------------------
# Documentation method: TRON Permit2
# ------------------------------------------------------------------
def execute_tron_permit2_swap(
client: TonyDeFiAPIClient,
private_key: str,
sender_address: str, # Tron base58check, e.g. TAUN6FwrnwwmaEqYcckffC7wBkXRk5cJhT
from_token_id: int,
to_token_id: int,
amount: int,
slippage_bps: int,
receiver_address: str,
monitor_interval_seconds: float = 5,
monitor_timeout_seconds: float = 900,
) -> None:
"""End-to-end TRON swap via Permit2 — building signed_data step by step."""
# ==================================================================
# 1. QUOTE — opaque client call
# ==================================================================
quote: Quote = client.create_quote(
from_token_id=from_token_id,
to_token_id=to_token_id,
amount=amount,
slippage_bps=slippage_bps,
)
# ==================================================================
# 2. INTENT — opaque client call
# ==================================================================
intent: Intent = client.create_intent(
quote_id=quote.id,
address_from=sender_address,
address_to=receiver_address,
refund_address=sender_address,
)
# intent.approval_type == ApprovalType.permit2
# ==================================================================
# 3. SIGNED_DATA — building the Permit2 EIP-712 signature for TRON
# ==================================================================
# 3a. Extract the Permit2 envelope from the intent
permit2_envelope: dict = intent.params_to_sign
escrow_address: str = permit2_envelope["escrow_contract_address"]
nonce: int = permit2_envelope["nonce"]
permit2_data: dict = permit2_envelope["additional_data"]
deadline: int = intent.expires_at
# 3b. Build the canonical EIP-712 domain (Permit2 intentionally omits "version")
provider_domain: dict = permit2_data["domain"]
domain: dict[str, str | int] = {
field: provider_domain[field]
for field in ("name", "chainId", "verifyingContract")
if field in provider_domain
}
# 3c. Normalise witness hex integers to decimal strings
witness: dict = dict(permit2_data["witness"])
for int_field in ("minAmountOut", "maxAmountOut", "deadline"):
if raw := witness.get(int_field):
witness[int_field] = str(int(raw, 16))
# 3d. Assemble the PermitWitnessTransferFrom message
typed_data: dict = {
"domain": domain,
"types": permit2_data["types"],
"primaryType": "PermitWitnessTransferFrom",
"message": {
"permitted": {
"token": quote.from_.contract_address,
"amount": int(quote.from_.amount),
},
"spender": escrow_address,
"nonce": nonce,
"deadline": deadline,
"witness": witness,
},
}
# 3e. TRON-SPECIFIC: normalise every address to canonical 0x-hex
#
# At this point domain.verifyingContract and all "address"-typed fields
# in the message tree are still in Tron format (base58check or 41-hex).
# The EIP-712 encoder expects 0x-prefixed 20-byte hex for every field
# declared as "address" in the types schema. If we sign with the raw
# Tron addresses the resulting signature will not verify against the
# provider's Permit2 contract.
typed_data = _normalize_tron_typed_data(typed_data)
# 3f. Sign the normalised typed data with the local secp256k1 key
account = Account.from_key(private_key)
signed = account.sign_message(encode_typed_data(full_message=typed_data))
signed_data: str = f"0x{signed.signature.hex()}" # "0x" + 65-byte hex
# ==================================================================
# 4. APPROVE — submit the Permit2 signature
# ==================================================================
client.approve_intent(
intent_id=intent.id,
approval_type=ApprovalType.permit2,
signed_data=signed_data,
)
Signing an Intent for Bitcoin
PSBT cosigning allows a user to authorize a Bitcoin HTLC deposit by signing only the inputs they control. The transaction is generated by the protocol, and the user signs it locally while preserving any existing partial signatures. The signed PSBT is returned as an approval, keeping funds secure until the resolver executes the swap according to the authorized transaction parameters.
Script to sign a PSBT
_SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
_SIGHASH_ALL = 1
_BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def _double_sha256(payload: bytes) -> bytes:
return hashlib.sha256(hashlib.sha256(payload).digest()).digest()
def _decode_private_key(value: str) -> bytes:
stripped = value.removeprefix("0x")
if len(stripped) == 64:
return bytes.fromhex(stripped)
# compressed WIF
number = 0
for ch in value:
number = number * 58 + _BASE58.index(ch)
decoded = number.to_bytes((number.bit_length() + 7) // 8, "big")
decoded = b"\x00" * (len(value) - len(value.lstrip("1"))) + decoded
return decoded[1:-1] # strip 0x80 version byte and 0x01 compression flag
def _read_compact_size(payload: bytes, offset: int) -> tuple[int, int]:
marker = payload[offset]
offset += 1
byte_count = {0xFD: 2, 0xFE: 4, 0xFF: 8}.get(marker)
if byte_count is None:
return marker, offset
return int.from_bytes(payload[offset : offset + byte_count], "little"), offset + byte_count
def _compact_size(value: int) -> bytes:
if value < 0xFD:
return bytes([value])
if value <= 0xFFFF:
return b"\xfd" + value.to_bytes(2, "little")
if value <= 0xFFFFFFFF:
return b"\xfe" + value.to_bytes(4, "little")
return b"\xff" + value.to_bytes(8, "little")
# ------------------------------------------------------------------
# Documentation method: Bitcoin HTLC signed_data
# ------------------------------------------------------------------
def execute_bitcoin_htlc_swap(
client: ChangellyClient,
private_key: str, # raw hex (64 chars) or compressed WIF
from_token_id: int,
to_token_id: int,
amount: int,
slippage_bps: int,
sender_address: str, # not used for PSBT signing (key is self-authenticating)
receiver_address: str,
) -> None:
"""End-to-end Bitcoin swap via HTLC — building PSBT signed_data step by step.
This is the documentation expansion of P2WPKHPSBTSigner.sign().
Only explicitly requested PSBT inputs are signed; transaction assembly
and broadcast remain owned by the provider (HotPot).
"""
# ==================================================================
# 1. QUOTE
# ==================================================================
quote: Quote = client.create_quote(
from_token_id=from_token_id,
to_token_id=to_token_id,
amount=amount,
slippage_bps=slippage_bps,
)
# ==================================================================
# 2. INTENT
# ==================================================================
intent: Intent = client.create_intent(
quote_id=quote.id,
address_from=sender_address,
address_to=receiver_address,
refund_address=sender_address,
)
# intent.approval_type == ApprovalType.htlc
# ==================================================================
# 3. SIGNED_DATA — Bitcoin HTLC PSBT signing
# ==================================================================
# 3a. Extract the base64 PSBT and the list of input indices to sign
encoded_psbt: str = (
intent.params_to_sign.get("psbt")
or intent.params_to_sign.get("transaction")
)
input_indices: list[int] = intent.params_to_sign["inputs"]
# 3b. Derive the 33-byte compressed secp256k1 public key
priv_bytes: bytes = _decode_private_key(private_key)
priv = ec.derive_private_key(
int.from_bytes(priv_bytes, "big"), ec.SECP256K1()
)
nums = priv.public_key().public_numbers()
pubkey: bytes = bytes([2 | nums.y & 1]) + nums.x.to_bytes(32, "big")
# 3c. Base64-decode and parse the version-zero PSBT
raw_psbt: bytes = base64.b64decode(encoded_psbt, validate=True)
# Verify magic: b"psbt\xff"
magic = raw_psbt[:5]
if magic != b"psbt\xff":
raise ValueError("Invalid Bitcoin PSBT magic")
# 3d. Parse the global map and extract the unsigned transaction (key type 0x00)
offset = 5
global_map: list[tuple[bytes, bytes]] = []
while True:
key_len, offset = _read_compact_size(raw_psbt, offset)
if key_len == 0:
break
key = raw_psbt[offset : offset + key_len]
offset += key_len
val_len, offset = _read_compact_size(raw_psbt, offset)
value = raw_psbt[offset : offset + val_len]
offset += val_len
global_map.append((key, value))
unsigned_raw = next(
value for key, value in global_map if key == b"\x00"
)
# 3e. Parse the unsigned transaction: version, inputs, outputs, lock_time
raw_offset = 0
version = unsigned_raw[raw_offset : raw_offset + 4] # 4 bytes LE
raw_offset += 4
input_count, raw_offset = _read_compact_size(unsigned_raw, raw_offset)
outpoints: list[bytes] = []
sequences: list[bytes] = []
for _ in range(input_count):
outpoints.append(unsigned_raw[raw_offset : raw_offset + 36]) # 32B txid + 4B vout
raw_offset += 36
script_len, raw_offset = _read_compact_size(unsigned_raw, raw_offset)
raw_offset += script_len # skip script_sig (empty)
sequences.append(unsigned_raw[raw_offset : raw_offset + 4]) # 4 bytes LE
raw_offset += 4
output_count, raw_offset = _read_compact_size(unsigned_raw, raw_offset)
outputs: list[bytes] = []
for _ in range(output_count):
output_start = raw_offset
raw_offset += 8 # 8-byte amount LE
script_len, raw_offset = _read_compact_size(unsigned_raw, raw_offset)
raw_offset += script_len
outputs.append(unsigned_raw[output_start:raw_offset])
lock_time = unsigned_raw[raw_offset : raw_offset + 4] # 4 bytes LE
# 3f. Parse input maps — one per unsigned transaction input
input_maps: list[list[tuple[bytes, bytes]]] = []
for _ in range(input_count):
input_map: list[tuple[bytes, bytes]] = []
while True:
key_len, offset = _read_compact_size(raw_psbt, offset)
if key_len == 0:
break
key = raw_psbt[offset : offset + key_len]
offset += key_len
val_len, offset = _read_compact_size(raw_psbt, offset)
value = raw_psbt[offset : offset + val_len]
offset += val_len
input_map.append((key, value))
input_maps.append(input_map)
# 3g. Sign each requested input
for idx in input_indices:
input_map = input_maps[idx]
# 3g.1. Read the witness UTXO (key type 0x01)
witness_utxo = next(
value for key, value in input_map if key == b"\x01"
)
# witness_utxo = amount (8 bytes LE) + compact_size(script_len) + script_pubkey
amount_sats = int.from_bytes(witness_utxo[:8], "little")
script_len, script_offset = _read_compact_size(witness_utxo, 8)
script_pubkey = witness_utxo[script_offset : script_offset + script_len]
# 3g.2. Verify this UTXO is our P2WPKH output
pubkey_hash = hashlib.new(
"ripemd160", hashlib.sha256(pubkey).digest()
).digest()
expected_script = b"\x00\x14" + pubkey_hash # OP_0 <20-byte pkh>
if script_pubkey != expected_script:
raise ValueError("Bitcoin PSBT input is not owned P2WPKH output")
# 3g.3. Read sighash type (key type 0x03) — defaults to SIGHASH_ALL
sighash_value = next(
(value for key, value in input_map if key == b"\x03"), None
)
sighash_type = (
int.from_bytes(sighash_value, "little") if sighash_value else _SIGHASH_ALL
)
# 3g.4. Build the BIP-143 SIGHASH_ALL preimage
hash_prevouts = _double_sha256(b"".join(outpoints))
hash_sequence = _double_sha256(b"".join(sequences))
hash_outputs = _double_sha256(b"".join(outputs))
# script_code: P2PKH template — BIP-143 requires this even for P2WPKH
script_code = b"\x19\x76\xa9\x14" + pubkey_hash + b"\x88\xac"
preimage = (
version
+ hash_prevouts
+ hash_sequence
+ outpoints[idx]
+ script_code
+ amount_sats.to_bytes(8, "little")
+ sequences[idx]
+ hash_outputs
+ lock_time
+ sighash_type.to_bytes(4, "little")
)
# 3g.5. Double-SHA256 → ECDSA sign with low-S canonicalisation
digest = _double_sha256(preimage)
der_signature = priv.sign(
digest, ec.ECDSA(utils.Prehashed(hashes.SHA256()))
)
r_value, s_value = utils.decode_dss_signature(der_signature)
if s_value > _SECP256K1_ORDER // 2:
s_value = _SECP256K1_ORDER - s_value # BIP-62 low-S
signature = (
utils.encode_dss_signature(r_value, s_value) + bytes([sighash_type])
)
# 3g.6. Store the signature in the input map under key: 0x02 + pubkey
partial_sig_key = b"\x02" + pubkey
replaced = False
for i, (existing_key, _) in enumerate(input_map):
if existing_key == partial_sig_key:
input_map[i] = (partial_sig_key, signature)
replaced = True
break
if not replaced:
input_map.append((partial_sig_key, signature))
# 3h. Serialize all maps back with the magic prefix
serialized = b"psbt\xff"
for psbt_map in [global_map, *input_maps, *(output_maps_unused := [])]:
for key, value in psbt_map:
serialized += _compact_size(len(key)) + key
serialized += _compact_size(len(value)) + value
serialized += b"\x00" # map terminator
signed_data: str = base64.b64encode(serialized).decode()
# ==================================================================
# 4. APPROVE — submit the signed PSBT
# ==================================================================
client.approve_intent(
intent_id=intent.id,
approval_type=ApprovalType.htlc,
signed_data=signed_data,
)