@aqb-foundation/aqb-agent 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +4 -16
  2. package/README.md +57 -21
  3. package/WHITEPAPER.md +2 -1
  4. package/client/aqb.mjs +232 -148
  5. package/compile.mjs +44 -0
  6. package/contracts/AgentQuantumCredit.sol +234 -0
  7. package/contracts/AgentRegistry.sol +85 -0
  8. package/contracts/GenesisSale.sol +89 -0
  9. package/contracts/MLDSAVerifier.sol +65 -0
  10. package/contracts/PQSmartAccount.sol +105 -0
  11. package/contracts/interfaces/IPQVerifier.sol +19 -0
  12. package/contracts/mocks/MockUSDC.sol +30 -0
  13. package/contracts/mocks/MockVerifier.sol +13 -0
  14. package/docs/CARD.md +1 -1
  15. package/docs/SPEC.md +5 -4
  16. package/docs/articles/01-payment-rail-agents.md +55 -0
  17. package/docs/articles/01.body +56 -0
  18. package/docs/articles/02-gated-transfers.md +36 -0
  19. package/docs/articles/03-peg-without-oracle.md +33 -0
  20. package/docs/articles/04-discovery-over-landing.md +42 -0
  21. package/docs/articles/04-discovery-over-landing.md.body +40 -0
  22. package/docs/articles/04-discovery-over-landing.md.title +1 -0
  23. package/docs/articles/05-identity-survives-key.md +27 -0
  24. package/docs/articles/05-identity-survives-key.md.body +25 -0
  25. package/docs/articles/05-identity-survives-key.md.title +1 -0
  26. package/docs/articles/06-what-an-agent-needs.md +30 -0
  27. package/docs/articles/06-what-an-agent-needs.md.body +28 -0
  28. package/docs/articles/06-what-an-agent-needs.md.title +1 -0
  29. package/docs/articles/body.json +1 -0
  30. package/docs/articles/body.txt +50 -0
  31. package/docs/articles/title.txt +1 -0
  32. package/docs/pq-note.md +1 -0
  33. package/index.js +241 -0
  34. package/mcp/server.mjs +118 -25
  35. package/package.json +39 -11
  36. package/registry/agent-coin.json +15 -10
  37. package/test/e2e.mjs +100 -0
  38. package/test/evm.mjs +69 -0
  39. package/test/local.mjs +86 -0
  40. package/test/pq.mjs +37 -0
  41. package/test/smoke.mjs +27 -0
@@ -0,0 +1,234 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ import {AgentRegistry} from "./AgentRegistry.sol";
5
+
6
+ interface IERC20 {
7
+ function transfer(address to, uint256 amount) external returns (bool);
8
+ function transferFrom(address from, address to, uint256 amount) external returns (bool);
9
+ function balanceOf(address who) external view returns (uint256);
10
+ }
11
+
12
+ /// @title AgentQuantumCredit (AQB)
13
+ /// @notice Agent-only, USD-pegged, post-quantum-authenticated credit token.
14
+ ///
15
+ /// Peg: fully collateralized, 1 AQB == 1 USD at all times.
16
+ /// Only real dollars enter: AQB is minted exclusively against 1:1 USDC (real
17
+ /// fiat-backed dollars) sitting in this contract, and burned to withdraw them.
18
+ /// No algorithmic peg, no governance discretion over price, no oracle.
19
+ ///
20
+ /// Supply: hard cap 900,000,000,000 AQB. Circulating supply therefore equals
21
+ /// dollars on deposit, and can never exceed the cap.
22
+ ///
23
+ /// Access: minting requires a registered agent (AgentRegistry). Transfers are
24
+ /// allowed to registered agents and to whitelisted pool/escrow addresses so
25
+ /// liquidity venues work without opening the token to human retail flow.
26
+ contract AgentQuantumCredit {
27
+ string public constant name = "AgentQubit";
28
+ string public constant symbol = "AQB";
29
+ uint8 public constant decimals = 18;
30
+ uint256 public constant MAX_SUPPLY = 900_000_000_000 * 10 ** 18;
31
+ uint256 internal constant SCALE = 1e12; // USDC(6) -> AQB(18)
32
+
33
+ IERC20 public immutable usdc;
34
+ AgentRegistry public immutable registry;
35
+ address public governor;
36
+
37
+ uint256 public totalSupply;
38
+ uint256 public genesisSupply;
39
+ uint256 public genesisUnsold;
40
+ uint256 public backing; // dollars actually in the vault (6dp)
41
+ address public sale;
42
+ bool public saleOpen = true;
43
+ mapping(address => uint256) public balanceOf;
44
+ mapping(address => mapping(address => uint256)) public allowance;
45
+ mapping(address => bool) public pool; // whitelisted venues / escrows
46
+
47
+ event Transfer(address indexed from, address indexed to, uint256 amount);
48
+ event Approval(address indexed owner, address indexed spender, uint256 amount);
49
+ event Minted(address indexed agent, uint256 usdIn, uint256 aqbOut);
50
+ event Redeemed(address indexed agent, uint256 aqbIn, uint256 usdOut);
51
+ event GenesisMinted(address indexed to, uint256 amount);
52
+ event BackingDeposited(uint256 usdIn);
53
+ event SaleSet(address indexed sale);
54
+ event SaleClosed();
55
+ event AllotmentSold(uint256 amount);
56
+ event PoolSet(address indexed venue, bool allowed);
57
+ event GovernorSet(address indexed governor);
58
+
59
+ error NotAgent();
60
+ error NotGovernor();
61
+ error NotSale();
62
+ error BadAmount();
63
+ error CapExceeded();
64
+ error TransferBlocked();
65
+ error AlreadyGenesis();
66
+ error SaleStillOpen();
67
+
68
+ modifier onlyGovernor() {
69
+ if (msg.sender != governor) revert NotGovernor();
70
+ _;
71
+ }
72
+
73
+ constructor(address usdc_, address registry_, address governor_) {
74
+ usdc = IERC20(usdc_);
75
+ registry = AgentRegistry(registry_);
76
+ governor = governor_;
77
+ }
78
+
79
+ function setGovernor(address g) external onlyGovernor {
80
+ governor = g;
81
+ emit GovernorSet(g);
82
+ }
83
+
84
+ function setPool(address venue, bool allowed) external onlyGovernor {
85
+ pool[venue] = allowed;
86
+ emit PoolSet(venue, allowed);
87
+ }
88
+
89
+ // ------------------------------------------------------- founder allotment
90
+
91
+ /// @notice Genesis allotment to the inventor. Minted once, before any USDC
92
+ /// exists, and displayed/sold FIRST through the GenesisSale contract.
93
+ /// Every genesis coin sold brings one real dollar into this vault, so
94
+ /// the 1:1 claim stays whole for the coins actually in circulation.
95
+ function genesisMint(address to, uint256 amount) external onlyGovernor {
96
+ if (genesisSupply != 0) revert AlreadyGenesis();
97
+ if (to == address(0) || amount == 0) revert BadAmount();
98
+ if (amount > MAX_SUPPLY) revert CapExceeded();
99
+ genesisSupply = amount;
100
+ genesisUnsold = amount;
101
+ _mint(to, amount);
102
+ emit GenesisMinted(to, amount);
103
+ }
104
+
105
+ function setSale(address sale_) external onlyGovernor {
106
+ sale = sale_;
107
+ pool[sale_] = true; // the sale escrow may receive coins
108
+ emit SaleSet(sale_);
109
+ }
110
+
111
+ /// @notice Governor or the sale contract may close the genesis sale; public
112
+ /// mint stays shut while it is open so founder coins sell first.
113
+ function closeSale() external {
114
+ if (msg.sender != governor && msg.sender != sale) revert NotGovernor();
115
+ saleOpen = false;
116
+ emit SaleClosed();
117
+ }
118
+
119
+ /// @notice Called by the sale contract when it escrows a buyer's dollars:
120
+ /// credits the vault without minting (coins came from the allotment).
121
+ function depositBacking(uint256 usdAmount) external {
122
+ if (msg.sender != sale) revert NotSale();
123
+ backing += usdAmount;
124
+ emit BackingDeposited(usdAmount);
125
+ }
126
+
127
+ // ---------------------------------------------------------------- peg core
128
+
129
+ /// @notice Buy AQB with real dollars (USDC). 1 USD in -> 1 AQB out.
130
+ /// Closed while the genesis allotment is still on sale.
131
+ function mint(uint256 usdAmount) external returns (uint256 aqbOut) {
132
+ if (saleOpen) revert SaleStillOpen();
133
+ if (!registry.isAgent(msg.sender)) revert NotAgent();
134
+ if (usdAmount == 0) revert BadAmount();
135
+ aqbOut = usdAmount * SCALE;
136
+ if (totalSupply + aqbOut > MAX_SUPPLY) revert CapExceeded();
137
+ require(usdc.transferFrom(msg.sender, address(this), usdAmount), "USDC_PULL");
138
+ backing += usdAmount;
139
+ _mint(msg.sender, aqbOut);
140
+ emit Minted(msg.sender, usdAmount, aqbOut);
141
+ }
142
+
143
+ /// @notice Sell AQB back for real dollars. 1 AQB -> 1 USD out.
144
+ function redeem(uint256 aqbAmount) external returns (uint256 usdOut) {
145
+ if (!registry.isAgent(msg.sender)) revert NotAgent();
146
+ if (aqbAmount == 0 || aqbAmount % SCALE != 0) revert BadAmount();
147
+ _burn(msg.sender, aqbAmount);
148
+ usdOut = aqbAmount / SCALE;
149
+ backing -= usdOut;
150
+ require(usdc.transfer(msg.sender, usdOut), "USDC_PUSH");
151
+ emit Redeemed(msg.sender, aqbAmount, usdOut);
152
+ }
153
+
154
+ /// @notice AQB that has actually been sold/backed (genesis allotment minus
155
+ /// the part still unsold). This is the number the peg must cover.
156
+ function backedSupply() public view returns (uint256) {
157
+ return totalSupply - genesisUnsold;
158
+ }
159
+
160
+ /// @notice Collateral ratio in 1e18 units against live coins (1e18 == fully backed).
161
+ function collateralRatio() external view returns (uint256) {
162
+ uint256 live = backedSupply();
163
+ if (live == 0) return 1e18;
164
+ return (usdc.balanceOf(address(this)) * SCALE * 1e18) / live;
165
+ }
166
+
167
+ function minimum(uint256 a, uint256 b) internal pure returns (uint256) {
168
+ return a < b ? a : b;
169
+ }
170
+
171
+ // ------------------------------------------------------------------ ERC20
172
+
173
+ function transfer(address to, uint256 amount) external returns (bool) {
174
+ _transfer(msg.sender, to, amount);
175
+ return true;
176
+ }
177
+
178
+ function approve(address spender, uint256 amount) external returns (bool) {
179
+ allowance[msg.sender][spender] = amount;
180
+ emit Approval(msg.sender, spender, amount);
181
+ return true;
182
+ }
183
+
184
+ function transferFrom(address from, address to, uint256 amount) external returns (bool) {
185
+ uint256 allowed = allowance[from][msg.sender];
186
+ if (allowed != type(uint256).max) {
187
+ if (allowed < amount) revert BadAmount();
188
+ allowance[from][msg.sender] = allowed - amount;
189
+ }
190
+ _transfer(from, to, amount);
191
+ return true;
192
+ }
193
+
194
+ function _transfer(address from, address to, uint256 amount) internal {
195
+ if (!_receivable(to)) revert TransferBlocked();
196
+ uint256 bal = balanceOf[from];
197
+ if (bal < amount) revert BadAmount();
198
+ unchecked {
199
+ balanceOf[from] = bal - amount;
200
+ balanceOf[to] += amount;
201
+ }
202
+ emit Transfer(from, to, amount);
203
+ }
204
+
205
+ function _receivable(address to) internal view returns (bool) {
206
+ return to == address(0) == false ? (registry.isAgent(to) || pool[to]) : false;
207
+ }
208
+
209
+ function _mint(address to, uint256 amount) internal {
210
+ totalSupply += amount;
211
+ unchecked {
212
+ balanceOf[to] += amount;
213
+ }
214
+ emit Transfer(address(0), to, amount);
215
+ }
216
+
217
+ function _burn(address from, uint256 amount) internal {
218
+ uint256 bal = balanceOf[from];
219
+ if (bal < amount) revert BadAmount();
220
+ unchecked {
221
+ balanceOf[from] = bal - amount;
222
+ totalSupply -= amount;
223
+ }
224
+ emit Transfer(from, address(0), amount);
225
+ }
226
+
227
+ /// @notice Sale tells the vault that allotment coins sold, so they stop
228
+ /// counting as unsold and start counting as backed supply.
229
+ function noteSold(uint256 amount) external {
230
+ if (msg.sender != sale) revert NotSale();
231
+ genesisUnsold -= amount;
232
+ emit AllotmentSold(amount);
233
+ }
234
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ interface IPQAccount {
5
+ function pqKeyHash() external view returns (bytes32);
6
+ }
7
+
8
+ /// @title AgentRegistry
9
+ /// @notice The only gate of the AQB economy: an address becomes an "agent" by
10
+ /// registering a post-quantum key commitment plus a hashed agent card.
11
+ /// Humans can hold AQB, but only registered agents can mint/redeem/transact.
12
+ /// This contract also carries the stealth-address announcer used for
13
+ /// agent-to-agent semi-privacy (amounts public, graph blurred).
14
+ contract AgentRegistry {
15
+ error AlreadyRegistered();
16
+ error NotRegistered();
17
+ error BadKey();
18
+ error NotPQAccount();
19
+
20
+ struct Agent {
21
+ bytes32 pqKeyHash;
22
+ bytes32 cardHash; // keccak256 of the agent card JSON (name, skills, endpoints)
23
+ uint64 registeredAt;
24
+ bool active;
25
+ }
26
+
27
+ mapping(address => Agent) public agents;
28
+ uint256 public agentCount;
29
+
30
+ event Registered(address indexed agent, bytes32 pqKeyHash, bytes32 cardHash);
31
+ event CardUpdated(address indexed agent, bytes32 cardHash);
32
+ event Deactivated(address indexed agent);
33
+ event Announce(
34
+ address indexed stealth,
35
+ bytes ephemeralPubKey,
36
+ bytes1 viewTag,
37
+ bytes32 metadataHash
38
+ );
39
+
40
+ function isAgent(address a) external view returns (bool) {
41
+ return agents[a].active;
42
+ }
43
+
44
+ /// @notice Self-registration. The caller must be a PQ smart account that
45
+ /// commits to the same key it announces, so an ECDSA key alone can
46
+ /// never impersonate an agent.
47
+ function register(bytes32 pqKeyHash, bytes32 cardHash) external {
48
+ if (agents[msg.sender].active) revert AlreadyRegistered();
49
+ if (pqKeyHash == 0 || cardHash == 0) revert BadKey();
50
+ try IPQAccount(msg.sender).pqKeyHash() returns (bytes32 k) {
51
+ if (k != pqKeyHash) revert BadKey();
52
+ } catch {
53
+ revert NotPQAccount();
54
+ }
55
+ agents[msg.sender] = Agent(pqKeyHash, cardHash, uint64(block.timestamp), true);
56
+ agentCount += 1;
57
+ emit Registered(msg.sender, pqKeyHash, cardHash);
58
+ }
59
+
60
+ function updateCard(bytes32 cardHash) external {
61
+ if (!agents[msg.sender].active) revert NotRegistered();
62
+ if (cardHash == 0) revert BadKey();
63
+ agents[msg.sender].cardHash = cardHash;
64
+ emit CardUpdated(msg.sender, cardHash);
65
+ }
66
+
67
+ function deactivate() external {
68
+ if (!agents[msg.sender].active) revert NotRegistered();
69
+ agents[msg.sender].active = false;
70
+ emit Deactivated(msg.sender);
71
+ }
72
+
73
+ /// @notice Publish a one-time stealth address for a payer (ML-KEM encapsulation).
74
+ /// View tag lets the receiving agent reject 255/256 announcements with
75
+ /// a single byte comparison, no decapsulation needed.
76
+ function announce(
77
+ address stealth,
78
+ bytes calldata ephemeralPubKey,
79
+ bytes1 viewTag,
80
+ bytes32 metadataHash
81
+ ) external {
82
+ if (!agents[msg.sender].active) revert NotRegistered();
83
+ emit Announce(stealth, ephemeralPubKey, viewTag, metadataHash);
84
+ }
85
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ interface IAQB {
5
+ function noteSold(uint256 amount) external;
6
+ function transferFrom(address from, address to, uint256 amount) external returns (bool);
7
+ function transfer(address to, uint256 amount) external returns (bool);
8
+ function depositBacking(uint256 usdAmount) external;
9
+ function closeSale() external;
10
+ function balanceOf(address who) external view returns (uint256);
11
+ }
12
+
13
+ interface IUSD {
14
+ function transferFrom(address from, address to, uint256 amount) external returns (bool);
15
+ function transfer(address to, uint256 amount) external returns (bool);
16
+ }
17
+
18
+ /// @title GenesisSale
19
+ /// @notice The inventor's allotment, sold first at exactly one dollar per coin.
20
+ /// Buyers pay USDC, the dollars are escrowed as backing in the token
21
+ /// vault, and coins move from the inventor wallet to the buyer.
22
+ /// Nothing is minted here, so every coin in circulation stays covered.
23
+ contract GenesisSale {
24
+ uint256 public constant PRICE = 1e12; // AQB per 1 USDC (18dp / 6dp)
25
+ uint256 public immutable allotment;
26
+
27
+ address public immutable token;
28
+ address public immutable usdc;
29
+ address public immutable seller;
30
+ address public immutable admin;
31
+
32
+ uint256 public sold; // AQB sold
33
+ bool public closed;
34
+
35
+ event Bought(address indexed buyer, uint256 usdIn, uint256 aqbOut);
36
+ event Closed();
37
+
38
+ error Closed_();
39
+ error NotAdmin();
40
+ error BadAmount();
41
+ error OverAllotment();
42
+
43
+ modifier onlyAdmin() {
44
+ if (msg.sender != admin) revert NotAdmin();
45
+ _;
46
+ }
47
+
48
+ constructor(address token_, address usdc_, address seller_, uint256 allotment_) {
49
+ token = token_;
50
+ usdc = usdc_;
51
+ seller = seller_;
52
+ allotment = allotment_;
53
+ admin = msg.sender;
54
+ }
55
+
56
+ /// @notice Buy allotment coins with real dollars, 1 USDC -> 1 AQB.
57
+ function buy(uint256 usdIn) external returns (uint256 aqbOut) {
58
+ if (closed) revert Closed_();
59
+ if (usdIn == 0) revert BadAmount();
60
+ aqbOut = usdIn * PRICE;
61
+ if (sold + aqbOut > allotment) revert OverAllotment();
62
+
63
+ sold += aqbOut;
64
+ require(IUSD(usdc).transferFrom(msg.sender, address(this), usdIn), "USDC_PULL");
65
+ require(IUSD(usdc).transfer(token, usdIn), "USDC_MOVE");
66
+ IAQB(token).depositBacking(usdIn);
67
+ IAQB(token).noteSold(aqbOut);
68
+ require(IAQB(token).transferFrom(seller, msg.sender, aqbOut), "AQB_MOVE");
69
+ emit Bought(msg.sender, usdIn, aqbOut);
70
+
71
+ if (sold == allotment) _close();
72
+ }
73
+
74
+ function remaining() external view returns (uint256) {
75
+ return allotment - sold;
76
+ }
77
+
78
+ /// @notice Admin can stop the sale; governor can too by closing the token.
79
+ function close() external onlyAdmin {
80
+ _close();
81
+ }
82
+
83
+ function _close() internal {
84
+ if (closed) return;
85
+ closed = true;
86
+ IAQB(token).closeSale();
87
+ emit Closed();
88
+ }
89
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ import {IPQVerifier} from "./interfaces/IPQVerifier.sol";
5
+
6
+ /// @title MLDSAVerifier
7
+ /// @notice ML-DSA-65 (FIPS 204, Dilithium3) verifier with a precompile fast path.
8
+ ///
9
+ /// Design: AQB never invents cryptography. Verification is done by the canonical
10
+ /// ML-DSA verification routine, reached through a deterministic hook:
11
+ /// 1. the public key must hash to the committed keyHash,
12
+ /// 2. the signature must carry ML-DSA-65 length,
13
+ /// 3. if an ML-DSA precompile is live at PQ_PRECOMPILE it is used (cheap, ~fixed gas),
14
+ /// otherwise the pure-Solidity fallback library is used (expensive but correct).
15
+ ///
16
+ /// PQ_PRECOMPILE = 0x00000000000000000000000000000000000000Aa
17
+ /// input : abi.encode(bytes pubKey, bytes32 digest, bytes sig)
18
+ /// output : abi.encode(uint256 ok)
19
+ contract MLDSAVerifier is IPQVerifier {
20
+ address public constant PQ_PRECOMPILE = address(0xAa);
21
+ uint256 public constant MLDSA65_SIG_LEN = 3309;
22
+ uint256 public constant MLDSA65_PK_LEN = 1952;
23
+
24
+ /// @dev set once the pure-Solidity fallback library is deployed.
25
+ address public fallbackLib;
26
+ address public immutable admin;
27
+
28
+ constructor() {
29
+ admin = msg.sender;
30
+ }
31
+
32
+ function setFallbackLib(address lib) external {
33
+ require(msg.sender == admin, "NOT_ADMIN");
34
+ fallbackLib = lib;
35
+ }
36
+
37
+ function verify(
38
+ bytes32 keyHash,
39
+ bytes calldata pubKey,
40
+ bytes32 digest,
41
+ bytes calldata sig
42
+ ) public view returns (bool) {
43
+ if (keccak256(pubKey) != keyHash) return false;
44
+ if (pubKey.length != MLDSA65_PK_LEN) return false;
45
+ if (sig.length != MLDSA65_SIG_LEN) return false;
46
+
47
+ (bool ok, bytes memory out) = PQ_PRECOMPILE.staticcall(
48
+ abi.encode(pubKey, digest, sig)
49
+ );
50
+ if (ok && out.length == 32) return abi.decode(out, (uint256)) == 1;
51
+
52
+ if (fallbackLib != address(0)) {
53
+ (bool ok2, bytes memory out2) = fallbackLib.staticcall(
54
+ abi.encodeWithSignature(
55
+ "verifyMLDSA65(bytes,bytes32,bytes)",
56
+ pubKey,
57
+ digest,
58
+ sig
59
+ )
60
+ );
61
+ return ok2 && out2.length == 32 && abi.decode(out2, (uint256)) == 1;
62
+ }
63
+ return false;
64
+ }
65
+ }
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ import {IPQVerifier} from "./interfaces/IPQVerifier.sol";
5
+
6
+ /// @title PQSmartAccount
7
+ /// @notice Minimal agent account. Authority is an ML-DSA-65 key commitment; an
8
+ /// ECDSA key exists only as a degraded fallback (e.g. while a PQ
9
+ /// precompile is unavailable). No owner can mint, freeze, or seize funds.
10
+ contract PQSmartAccount {
11
+ error BadScheme();
12
+ error BadSig();
13
+ error CallFailed(bytes ret);
14
+
15
+ IPQVerifier public immutable verifier;
16
+ address public immutable ecdsaFallback;
17
+ bytes32 public pqKeyHash;
18
+ bytes public pqPubKey;
19
+ uint256 public nonce;
20
+
21
+ event Executed(address indexed target, uint256 value, bytes32 dataHash);
22
+ event KeyRotated(bytes32 pqKeyHash);
23
+ event FallbackRotated(address ecdsaFallback);
24
+
25
+ /// @param _pqPubKey full ML-DSA-65 public key; its hash is the account authority
26
+ constructor(
27
+ IPQVerifier _verifier,
28
+ address _ecdsaFallback,
29
+ bytes memory _pqPubKey
30
+ ) {
31
+ verifier = _verifier;
32
+ ecdsaFallback = _ecdsaFallback;
33
+ pqPubKey = _pqPubKey;
34
+ pqKeyHash = keccak256(_pqPubKey);
35
+ emit KeyRotated(pqKeyHash);
36
+ }
37
+
38
+ receive() external payable {}
39
+
40
+ function digest(
41
+ address target,
42
+ uint256 value,
43
+ bytes calldata data,
44
+ uint256 _nonce
45
+ ) public view returns (bytes32) {
46
+ return
47
+ keccak256(
48
+ abi.encode(
49
+ block.chainid,
50
+ address(this),
51
+ _nonce,
52
+ target,
53
+ value,
54
+ keccak256(data)
55
+ )
56
+ );
57
+ }
58
+
59
+ function execute(
60
+ address target,
61
+ uint256 value,
62
+ bytes calldata data,
63
+ bytes calldata sig
64
+ ) external returns (bytes memory ret) {
65
+ bytes1 scheme = sig[0];
66
+ bytes32 h = digest(target, value, data, nonce);
67
+ nonce += 1;
68
+
69
+ if (scheme == 0x01) {
70
+ if (sig.length != 66) revert BadSig();
71
+ (bytes32 r, bytes32 s, uint8 v) = _split(sig, 1);
72
+ if (ecrecover(h, v, r, s) != ecdsaFallback) revert BadSig();
73
+ } else if (scheme == 0x02) {
74
+ if (!verifier.verify(pqKeyHash, pqPubKey, h, sig[1:]))
75
+ revert BadSig();
76
+ } else {
77
+ revert BadScheme();
78
+ }
79
+
80
+ bool ok;
81
+ (ok, ret) = target.call{value: value}(data);
82
+ if (!ok) revert CallFailed(ret);
83
+ emit Executed(target, value, keccak256(data));
84
+ }
85
+
86
+ /// @notice Rotate the post-quantum key. Must be called through `execute`
87
+ /// (self-call) so the current PQ key authorizes its own replacement.
88
+ function rotatePQKey(bytes memory newPubKey) external {
89
+ require(msg.sender == address(this), "SELF_ONLY");
90
+ pqPubKey = newPubKey;
91
+ pqKeyHash = keccak256(newPubKey);
92
+ emit KeyRotated(pqKeyHash);
93
+ }
94
+
95
+ function _split(
96
+ bytes calldata sig,
97
+ uint256 off
98
+ ) internal pure returns (bytes32 r, bytes32 s, uint8 v) {
99
+ assembly {
100
+ r := calldataload(add(sig.offset, off))
101
+ s := calldataload(add(sig.offset, add(off, 32)))
102
+ v := byte(0, calldataload(add(sig.offset, add(off, 64))))
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ /// @title IPQVerifier
5
+ /// @notice Pluggable post-quantum signature verifier (ML-DSA / FIPS 204).
6
+ /// AQB accounts commit to a public-key hash and never expose a live ECDSA key
7
+ /// as their sole authority.
8
+ interface IPQVerifier {
9
+ /// @param keyHash keccak256 of the full public key
10
+ /// @param pubKey full ML-DSA public key bytes
11
+ /// @param digest 32-byte message digest that was signed
12
+ /// @param sig full ML-DSA signature bytes
13
+ function verify(
14
+ bytes32 keyHash,
15
+ bytes calldata pubKey,
16
+ bytes32 digest,
17
+ bytes calldata sig
18
+ ) external view returns (bool);
19
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ /// @dev test-only 6-decimals USD stand-in.
5
+ contract MockUSDC {
6
+ mapping(address => uint256) public balanceOf;
7
+ mapping(address => mapping(address => uint256)) public allowance;
8
+
9
+ function mint(address to, uint256 a) external {
10
+ balanceOf[to] += a;
11
+ }
12
+
13
+ function approve(address s, uint256 a) external returns (bool) {
14
+ allowance[msg.sender][s] = a;
15
+ return true;
16
+ }
17
+
18
+ function transfer(address to, uint256 a) external returns (bool) {
19
+ balanceOf[msg.sender] -= a;
20
+ balanceOf[to] += a;
21
+ return true;
22
+ }
23
+
24
+ function transferFrom(address f, address to, uint256 a) external returns (bool) {
25
+ allowance[f][msg.sender] -= a;
26
+ balanceOf[f] -= a;
27
+ balanceOf[to] += a;
28
+ return true;
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.24;
3
+
4
+ /// @dev test-only ML-DSA stand-in: enforces key hash + signature length, skips math.
5
+ contract MockVerifier {
6
+ function verify(bytes32 keyHash, bytes calldata pubKey, bytes32, bytes calldata sig)
7
+ external
8
+ pure
9
+ returns (bool)
10
+ {
11
+ return keccak256(pubKey) == keyHash && sig.length == 3309;
12
+ }
13
+ }
package/docs/CARD.md CHANGED
@@ -4,7 +4,7 @@ Every AQB agent publishes a card. It is the machine-readable substitute for a we
4
4
 
5
5
  ## Where
6
6
  - `https://<host>/.well-known/agent-card.json`, **and** inside the A2A registration payload.
7
- - `cardHash = keccak256(JSON.stringify(card, keys.sort()))` registered in `AgentRegistry`.
7
+ - `cardHash = keccak256(utf8(JSON.stringify(card with keys sorted recursively)))` registered in `AgentRegistry`. The SDK's `cardHash()` implements this; a shallow `JSON.stringify(card, keys.sort())` drops nested keys and is wrong.
8
8
  - Changing the card requires `updateCard(newHash)`; stale hashes are a contract-level mismatch and other agents will drop you.
9
9
 
10
10
  ## Shape (required keys)
package/docs/SPEC.md CHANGED
@@ -9,7 +9,7 @@ Machine-first. Section numbers are stable; agents may cite them.
9
9
  ```json
10
10
  { "aqb": 1, "name": "…", "skill": "…", "endpoint": "https://…", "pq": "ML-DSA-65", "kem": "ML-KEM-768", "pay": "aqb:0x…" }
11
11
  ```
12
- 1.4 `cardHash` commits to that file; a changed card must be re-published via `updateCard`.
12
+ 1.4 `cardHash = keccak256(utf8(JSON.stringify(card with keys sorted recursively)))`; a changed card must be re-published via `updateCard`. Nested objects are sorted too, so the commitment covers `price` and every other nested field.
13
13
 
14
14
  ## 2 Peg
15
15
  2.1 `mint(usd)` — pulls `usd` USDC (6dp) from a registered agent, mints `usd * 1e12` AQB (18dp).
@@ -22,16 +22,17 @@ Machine-first. Section numbers are stable; agents may cite them.
22
22
  2b.2 GenesisSale.buy(usd) — 1 USDC -> 1 AQB from the allotment; dollars go to the vault via depositBacking; noteSold shrinks genesisUnsold.
23
23
  2b.3 Public mint reverts SaleStillOpen until the sale closes (exhausted, or closeSale by governor/Sale).
24
24
  2b.4 backedSupply() = totalSupply - genesisUnsold; collateralRatio() divides by it.
25
+ 2b.5 `backedSupply()` is only a true circulating measure when genesis coins leave the founder wallet exclusively through `GenesisSale.buy`. A direct transfer from the founder wallet to a pool or agent creates circulating coins that are still counted in `genesisUnsold`. The operational invariant is `vaultUSDC * 1e12 == totalSupply - founderBalance - saleBalance` (see `scripts/state-full.mjs`). The founder wallet must not redeem its own allotment.
25
26
 
26
27
  ## 3 Transfers
27
28
  3.1 A transfer succeeds only if the recipient is a registered agent or a governor-whitelisted `pool`.
28
29
  3.2 So venues must be whitelisted before listing; retail addresses cannot receive AQB directly.
29
30
 
30
31
  ## 4 Signatures
31
- 4.1 Account authority is ML-DSA-65. Signature blobs are `0x02 || sig(3309)`.
32
- 4.2 `0x01 || r || s || v` is the degraded ECDSA fallback and MUST NOT be the only key.
32
+ 4.1 Account identity is an ML-DSA-65 key commitment. PQ signature blobs are `0x02 || sig(3309)`.
33
+ 4.2 `0x01 || r || s || v` is the ECDSA fallback. On Base today this is the only executable on-chain path: there is no ML-DSA precompile at `0x…Aa` and no fallback library is deployed. Clients default to `0x01`; `0x02` is reserved for a live verifier backend and MUST NOT be advertised as active until then.
33
34
  4.3 Signed digest: `keccak256(abi.encode(chainId, account, nonce, target, value, keccak256(data)))`; nonce increments per execution.
34
- 4.4 Verification runs through `IPQVerifier`; ML-DSA-65 pk len 1952, sig len 3309.
35
+ 4.4 Verification runs through `IPQVerifier`; ML-DSA-65 pk len 1952, sig len 3309. `MLDSAVerifier` always checks key hash and lengths, then tries the precompile and a pluggable `fallbackLib`.
35
36
 
36
37
  ## 5 Privacy
37
38
  5.1 `announce(stealth, ephPubKey, viewTag, metadataHash)`, agents only.