Skip to content

Commit 166d2f6

Browse files
feat: cross chain transfer service buyer example
1 parent 7a15d0b commit 166d2f6

5 files changed

Lines changed: 205 additions & 3 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import logging
2+
import threading
3+
import os
4+
import sys
5+
6+
# Add project root to Python path
7+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
8+
sys.path.insert(0, project_root)
9+
10+
from datetime import datetime, timedelta
11+
from typing import Optional
12+
13+
from dotenv import load_dotenv
14+
15+
from virtuals_acp.client import VirtualsACP
16+
from virtuals_acp.configs.configs import BASE_MAINNET_ACP_X402_CONFIG_V2, BASE_SEPOLIA_ACP_X402_CONFIG_V2
17+
from virtuals_acp.contract_clients.contract_client_v2 import ACPContractClientV2
18+
from virtuals_acp.env import EnvSettings
19+
from virtuals_acp.job import ACPJob
20+
from virtuals_acp.memo import ACPMemo
21+
from virtuals_acp.models import (
22+
ACPAgentSort,
23+
ACPJobPhase,
24+
ACPGraduationStatus,
25+
ACPOnlineStatus,
26+
ChainConfig
27+
)
28+
29+
# Configure logging
30+
logging.basicConfig(
31+
level=logging.INFO,
32+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
33+
)
34+
logger = logging.getLogger("BuyerAgent")
35+
36+
load_dotenv(override=True)
37+
38+
TARGET_CHAIN_ID = 97
39+
40+
config = BASE_SEPOLIA_ACP_X402_CONFIG_V2
41+
config.chains = [
42+
ChainConfig(
43+
chain_id=TARGET_CHAIN_ID,
44+
rpc_url="https://bsc-testnet-dataseed.bnbchain.org"
45+
)
46+
]
47+
48+
49+
def buyer():
50+
env = EnvSettings()
51+
52+
def on_new_task(job: ACPJob, memo_to_sign: Optional[ACPMemo] = None):
53+
if (
54+
job.phase == ACPJobPhase.NEGOTIATION
55+
and memo_to_sign is not None
56+
and memo_to_sign.next_phase == ACPJobPhase.TRANSACTION
57+
):
58+
logger.info(f"Paying for job {job.id}")
59+
job.pay_and_accept_requirement()
60+
logger.info(f"Job {job.id} paid")
61+
62+
elif (
63+
job.phase == ACPJobPhase.TRANSACTION
64+
and memo_to_sign is not None
65+
and memo_to_sign.next_phase == ACPJobPhase.REJECTED
66+
):
67+
logger.info(f"Signing job {job.id} rejection memo, rejection reason: {memo_to_sign.content}")
68+
memo_to_sign.sign(True, "Accepts job rejection")
69+
logger.info(f"Job {job.id} rejection memo signed")
70+
71+
elif job.phase == ACPJobPhase.COMPLETED:
72+
logger.info(f"Job {job.id} completed, received deliverable: {job.deliverable}")
73+
74+
elif job.phase == ACPJobPhase.REJECTED:
75+
logger.info(f"Job {job.id} rejected by seller")
76+
77+
acp_client = VirtualsACP(
78+
acp_contract_clients=ACPContractClientV2(
79+
wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY,
80+
agent_wallet_address=env.BUYER_AGENT_WALLET_ADDRESS,
81+
entity_id=env.BUYER_ENTITY_ID,
82+
config=config, # route to x402 for payment, undefined defaulted back to direct transfer
83+
),
84+
on_new_task=on_new_task
85+
)
86+
87+
# Browse available agents based on a keyword
88+
relevant_agents = acp_client.browse_agents(
89+
keyword="cross chain transfer service",
90+
sort_by=[ACPAgentSort.SUCCESSFUL_JOB_COUNT],
91+
top_k=5,
92+
graduation_status=ACPGraduationStatus.ALL,
93+
online_status=ACPOnlineStatus.ALL,
94+
show_hidden_offerings=True,
95+
)
96+
logger.info(f"Relevant agents: {relevant_agents}")
97+
98+
# Pick one of the agents based on your criteria (in this example we just pick the first one)
99+
chosen_agent = relevant_agents[0]
100+
# Pick one of the service offerings based on your criteria (in this example we just pick the first one)
101+
chosen_job_offering = chosen_agent.job_offerings[1]
102+
103+
job_id = chosen_job_offering.initiate_job(
104+
service_requirement={},
105+
expired_at=datetime.now() + timedelta(minutes=5), # job expiry duration, minimum 3 minutes
106+
)
107+
logger.info(f"Job {job_id} initiated")
108+
logger.info("Listening for next steps...")
109+
110+
threading.Event().wait()
111+
112+
113+
if __name__ == "__main__":
114+
buyer()

virtuals_acp/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
ACPAgentSort,
3232
ACPJobPhase,
3333
ACPGraduationStatus,
34+
ACPMemoState,
3435
ACPOnlineStatus,
3536
MemoType,
3637
IACPAgent,
@@ -160,6 +161,7 @@ def handle_new_task(self, data) -> None:
160161
payable_details=memo.get("payableDetails"),
161162
txn_hash=memo.get("txHash"),
162163
signed_txn_hash=memo.get("signedTxHash"),
164+
state=ACPMemoState(memo.get("state")),
163165
)
164166
for memo in data["memos"]
165167
]
@@ -214,6 +216,7 @@ def handle_evaluate(self, data) -> None:
214216
payable_details=memo.get("payableDetails"),
215217
txn_hash=memo.get("txHash"),
216218
signed_txn_hash=memo.get("signedTxHash"),
219+
state=ACPMemoState(memo.get("state")),
217220
)
218221
for memo in data["memos"]
219222
]
@@ -607,6 +610,7 @@ def _hydrate_jobs(
607610
payable_details=memo.get("payableDetails"),
608611
txn_hash=memo.get("txHash"),
609612
signed_txn_hash=memo.get("signedTxHash"),
613+
state=ACPMemoState(memo.get("state")),
610614
)
611615
for memo in job.get("memos", [])
612616
]
@@ -692,6 +696,7 @@ def get_job_by_onchain_id(self, onchain_job_id: int) -> "ACPJob":
692696
payable_details=memo.get("payableDetails"),
693697
txn_hash=memo.get("txHash"),
694698
signed_txn_hash=memo.get("signedTxHash"),
699+
state=ACPMemoState(memo.get("state")),
695700
)
696701
)
697702

@@ -750,6 +755,7 @@ def get_memo_by_id(self, onchain_job_id: int, memo_id: int) -> "ACPMemo":
750755
payable_details=memo.get("payableDetails"),
751756
txn_hash=memo.get("txHash"),
752757
signed_txn_hash=memo.get("signedTxHash"),
758+
state=ACPMemoState(memo.get("state")),
753759
)
754760

755761
except Exception as e:

virtuals_acp/job.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
from virtuals_acp.exceptions import ACPError
99
from virtuals_acp.memo import ACPMemo
1010
from virtuals_acp.models import (
11+
ACPMemoState,
1112
PriceType,
1213
OperationPayload,
1314
X402PayableRequest,
1415
X402PayableRequirements,
1516
RequestPayload,
1617
)
1718
from virtuals_acp.utils import (
19+
get_destination_chain_id,
1820
get_destination_endpoint_id,
1921
try_parse_json_model,
2022
prepare_payload,
@@ -28,7 +30,7 @@
2830
FeeType,
2931
)
3032
from virtuals_acp.fare import Fare, FareAmountBase, FareAmount
31-
from virtuals_acp.web3 import getERC20Allowance, getERC20Balance, getERC20Symbol
33+
from virtuals_acp.web3 import getERC20Allowance, getERC20Balance, getERC20Decimals, getERC20Symbol
3234

3335
if TYPE_CHECKING:
3436
from virtuals_acp.client import VirtualsACP
@@ -254,6 +256,57 @@ def pay_and_accept_requirement(self, reason: Optional[str] = "") -> str | None:
254256
if not memo:
255257
raise Exception("No negotiation memo found")
256258

259+
if memo.type == MemoType.PAYABLE_REQUEST and memo.state != ACPMemoState.PENDING and memo.payable_details is not None and memo.payable_details['lzDstEid'] is not None:
260+
print(f"Memo not ready to be signed, state: {memo.state}, payable_details: {memo.payable_details}")
261+
return
262+
263+
if memo.payable_details:
264+
if "lzDstEid" in memo.payable_details:
265+
destination_chain_id = get_destination_chain_id(memo.payable_details["lzDstEid"])
266+
else:
267+
destination_chain_id = self.acp_contract_client.config.chain_id
268+
269+
if(destination_chain_id != self.acp_contract_client.config.chain_id):
270+
token_balance = getERC20Balance(
271+
self.acp_contract_client.public_clients[destination_chain_id],
272+
memo.payable_details["token"],
273+
self.client_address,
274+
)
275+
276+
if token_balance < memo.payable_details["amount"]:
277+
token_decimals = getERC20Decimals(
278+
self.acp_contract_client.public_clients[destination_chain_id],
279+
memo.payable_details["token"],
280+
)
281+
282+
token_symbol = getERC20Symbol(
283+
self.acp_contract_client.public_clients[destination_chain_id],
284+
memo.payable_details["token"],
285+
)
286+
287+
raise ACPError(f"You do not have enough funds to pay for the job which costs {memo.payable_details['amount'] / 10 ** token_decimals} {token_symbol} on chainId {destination_chain_id}")
288+
else:
289+
asset_manager_address = self.acp_contract_client.get_asset_manager_address()
290+
291+
allowance = getERC20Allowance(
292+
self.acp_contract_client.public_clients[destination_chain_id],
293+
memo.payable_details["token"],
294+
self.client_address,
295+
asset_manager_address,
296+
)
297+
298+
destination_chain_operations: List[OperationPayload] = []
299+
300+
destination_chain_operations.append(
301+
self.acp_contract_client.approve_allowance(
302+
memo.payable_details["amount"] + allowance,
303+
memo.payable_details["token"],
304+
asset_manager_address,
305+
)
306+
)
307+
308+
self.acp_contract_client.handle_operation(destination_chain_operations, destination_chain_id)
309+
257310
operations: List[OperationPayload] = []
258311
base_fare_amount = FareAmount(self.price, self.base_fare)
259312

virtuals_acp/utils.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,21 @@ def get_destination_endpoint_id(chain_id: int) -> int:
7575
}
7676
if chain_id in id_to_eid:
7777
return id_to_eid[chain_id]
78-
raise ValueError(f"Unsupported chain ID: {chain_id}")
78+
raise ValueError(f"Unsupported chain ID: {chain_id}")
79+
80+
def get_destination_chain_id(endpoint_id: int) -> int:
81+
eid_to_id = {
82+
40245: 84532, # baseSepolia.id
83+
40161: 11155111, # sepolia.id
84+
40267: 80002, # polygonAmoy.id
85+
40231: 421614, # arbitrumSepolia.id
86+
40102: 97, # bscTestnet.id
87+
30184: 8453, # base.id
88+
30101: 1, # mainnet.id
89+
30109: 137, # polygon.id
90+
30110: 42161, # arbitrum.id
91+
30102: 56, # bsc.id
92+
}
93+
if endpoint_id in eid_to_id:
94+
return eid_to_id[endpoint_id]
95+
raise ValueError(f"Unsupported endpoint ID: {endpoint_id}")

virtuals_acp/web3.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,16 @@ def getERC20Symbol(
4141

4242
symbol = erc20_contract_instance.functions.symbol().call()
4343

44-
return symbol
44+
return symbol
45+
46+
def getERC20Decimals(
47+
public_client: Web3,
48+
contract_address: str,
49+
) -> int:
50+
erc20_contract_instance = public_client.eth.contract(
51+
address=contract_address, abi=ERC20_ABI
52+
)
53+
54+
decimals = erc20_contract_instance.functions.decimals().call()
55+
56+
return decimals

0 commit comments

Comments
 (0)