Emit entitlement calldata in quotes and verify tx payload
This commit is contained in:
parent
86a57b2888
commit
0696762d24
@ -12,6 +12,7 @@ SECRET_API_QUOTE_TTL_SECONDS=900
|
||||
SECRET_API_DOMAIN_NAME=EDUT Designation
|
||||
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
|
||||
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
|
||||
SECRET_API_ENTITLEMENT_CONTRACT=0x0000000000000000000000000000000000000000
|
||||
SECRET_API_MINT_CURRENCY=USDC
|
||||
SECRET_API_MINT_AMOUNT_ATOMIC=100000000
|
||||
SECRET_API_MINT_DECIMALS=6
|
||||
|
||||
@ -86,6 +86,7 @@ Company-first sponsor path is also supported:
|
||||
- `SECRET_API_CHAIN_ID` (default `84532`)
|
||||
- `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification)
|
||||
- `SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION` (default `false`; when `true`, membership confirm and marketplace checkout confirm fail closed without chain receipt verification)
|
||||
- `SECRET_API_ENTITLEMENT_CONTRACT` (optional; when set, marketplace quote emits purchase calldata for entitlement settlement contract)
|
||||
|
||||
### Membership
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@ -741,6 +742,38 @@ func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceQuoteUsesEntitlementContractTransactionWhenConfigured(t *testing.T) {
|
||||
a, _, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
a.cfg.EntitlementContract = "0x1111111111111111111111111111111111111111"
|
||||
ownerKey := mustKey(t)
|
||||
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||
t.Fatalf("seed active membership: %v", err)
|
||||
}
|
||||
|
||||
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
OrgRootID: "org.marketplace.txshape",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
WorkspaceID: "workspace.alpha",
|
||||
}, http.StatusOK)
|
||||
|
||||
if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["to"]))); got != strings.ToLower(a.cfg.EntitlementContract) {
|
||||
t.Fatalf("tx.to mismatch: got=%s want=%s quote=%+v", got, a.cfg.EntitlementContract, quote.Tx)
|
||||
}
|
||||
if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["from"]))); got != ownerAddr {
|
||||
t.Fatalf("tx.from mismatch: got=%s want=%s quote=%+v", got, ownerAddr, quote.Tx)
|
||||
}
|
||||
data := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["data"])))
|
||||
if !strings.HasPrefix(data, "0x") || data == "0x" {
|
||||
t.Fatalf("expected encoded entitlement calldata, got=%+v", quote.Tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) {
|
||||
a, _, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
@ -9,11 +9,14 @@ import (
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
)
|
||||
|
||||
const membershipABI = `[{"inputs":[{"internalType":"address","name":"recipient","type":"address"}],"name":"mintMembership","outputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"stateMutability":"payable","type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"wallet","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountPaid","type":"uint256"},{"indexed":false,"internalType":"address","name":"currency","type":"address"}],"name":"MembershipMinted","type":"event"}]`
|
||||
|
||||
const entitlementABI = `[{"inputs":[{"internalType":"string","name":"offerId","type":"string"},{"internalType":"address","name":"ownerWallet","type":"address"},{"internalType":"bytes32","name":"orgRootKey","type":"bytes32"},{"internalType":"bytes32","name":"workspaceKey","type":"bytes32"}],"name":"purchaseEntitlement","outputs":[{"internalType":"uint256","name":"entitlementId","type":"uint256"}],"stateMutability":"payable","type":"function"}]`
|
||||
|
||||
func encodeMintMembershipCalldata(recipient string) (string, error) {
|
||||
parsed, err := abi.JSON(strings.NewReader(membershipABI))
|
||||
if err != nil {
|
||||
@ -76,12 +79,27 @@ func verifyMintedOnChain(ctx context.Context, cfg Config, txHash string, expecte
|
||||
}
|
||||
|
||||
func verifyTransactionSenderOnChain(ctx context.Context, cfg Config, txHash string, expectedSender string) error {
|
||||
return verifyTransactionCallOnChain(ctx, cfg, txHash, expectedSender, "", "", "")
|
||||
}
|
||||
|
||||
func verifyTransactionCallOnChain(
|
||||
ctx context.Context,
|
||||
cfg Config,
|
||||
txHash string,
|
||||
expectedSender string,
|
||||
expectedTo string,
|
||||
expectedData string,
|
||||
expectedValueHex string,
|
||||
) error {
|
||||
if strings.TrimSpace(cfg.ChainRPCURL) == "" {
|
||||
return nil
|
||||
}
|
||||
expectedSender = strings.TrimSpace(expectedSender)
|
||||
if expectedSender == "" {
|
||||
return fmt.Errorf("expected sender missing")
|
||||
expectedTo = strings.TrimSpace(expectedTo)
|
||||
expectedData = strings.ToLower(strings.TrimSpace(expectedData))
|
||||
expectedValueHex = strings.ToLower(strings.TrimSpace(expectedValueHex))
|
||||
if expectedSender == "" && expectedTo == "" && expectedData == "" && expectedValueHex == "" {
|
||||
return fmt.Errorf("no expected tx parameters provided")
|
||||
}
|
||||
|
||||
client, err := ethclient.DialContext(ctx, cfg.ChainRPCURL)
|
||||
@ -120,10 +138,63 @@ func verifyTransactionSenderOnChain(ctx context.Context, cfg Config, txHash stri
|
||||
return fmt.Errorf("resolve tx sender: %w", err)
|
||||
}
|
||||
|
||||
if expectedSender != "" {
|
||||
gotSender := strings.ToLower(from.Hex())
|
||||
wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex())
|
||||
if gotSender != wantSender {
|
||||
return fmt.Errorf("tx sender mismatch got=%s want=%s", gotSender, wantSender)
|
||||
}
|
||||
}
|
||||
|
||||
if expectedTo != "" {
|
||||
if tx.To() == nil {
|
||||
return fmt.Errorf("tx target missing")
|
||||
}
|
||||
gotTo := strings.ToLower(tx.To().Hex())
|
||||
wantTo := strings.ToLower(common.HexToAddress(expectedTo).Hex())
|
||||
if gotTo != wantTo {
|
||||
return fmt.Errorf("tx target mismatch got=%s want=%s", gotTo, wantTo)
|
||||
}
|
||||
}
|
||||
|
||||
if expectedData != "" {
|
||||
gotData := "0x" + common.Bytes2Hex(tx.Data())
|
||||
if strings.ToLower(gotData) != expectedData {
|
||||
return fmt.Errorf("tx calldata mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
if expectedValueHex != "" {
|
||||
wantValue := new(big.Int)
|
||||
if _, ok := wantValue.SetString(strings.TrimPrefix(expectedValueHex, "0x"), 16); !ok {
|
||||
return fmt.Errorf("invalid expected tx value")
|
||||
}
|
||||
if tx.Value().Cmp(wantValue) != 0 {
|
||||
return fmt.Errorf("tx value mismatch got=%s want=%s", tx.Value().String(), wantValue.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodePurchaseEntitlementCalldata(offerID, ownerWallet, orgRootID, workspaceID string) (string, error) {
|
||||
parsed, err := abi.JSON(strings.NewReader(entitlementABI))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse entitlement abi: %w", err)
|
||||
}
|
||||
ownerAddress := common.HexToAddress(ownerWallet)
|
||||
orgRootKey := hashIdentifierKey(orgRootID)
|
||||
workspaceKey := hashIdentifierKey(workspaceID)
|
||||
data, err := parsed.Pack("purchaseEntitlement", strings.TrimSpace(offerID), ownerAddress, orgRootKey, workspaceKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack entitlement calldata: %w", err)
|
||||
}
|
||||
return "0x" + common.Bytes2Hex(data), nil
|
||||
}
|
||||
|
||||
func hashIdentifierKey(value string) common.Hash {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return common.Hash{}
|
||||
}
|
||||
return common.BytesToHash(crypto.Keccak256([]byte(value)))
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ type Config struct {
|
||||
DomainName string
|
||||
VerifyingContract string
|
||||
MembershipContract string
|
||||
EntitlementContract string
|
||||
MintCurrency string
|
||||
MintAmountAtomic string
|
||||
MintDecimals int
|
||||
@ -51,6 +52,7 @@ func loadConfig() Config {
|
||||
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
|
||||
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||
EntitlementContract: strings.ToLower(env("SECRET_API_ENTITLEMENT_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
|
||||
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
|
||||
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),
|
||||
|
||||
@ -376,6 +376,20 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
|
||||
return
|
||||
}
|
||||
txTo := strings.ToLower(strings.TrimSpace(a.cfg.MembershipContract))
|
||||
txData := "0x"
|
||||
txValueHex := "0x0"
|
||||
if entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract)); entitlementContract != "" &&
|
||||
!strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") {
|
||||
entitlementCalldata, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID)
|
||||
if calldataErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
|
||||
return
|
||||
}
|
||||
txTo = entitlementContract
|
||||
txData = entitlementCalldata
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(a.cfg.QuoteTTL)
|
||||
accessClass := "connected"
|
||||
@ -400,6 +414,9 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
PolicyHash: a.cfg.GovernancePolicyHash,
|
||||
AccessClass: accessClass,
|
||||
AvailabilityState: "active",
|
||||
ExpectedTxTo: txTo,
|
||||
ExpectedTxData: txData,
|
||||
ExpectedTxValueHex: txValueHex,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
@ -427,9 +444,10 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
|
||||
PolicyHash: quote.PolicyHash,
|
||||
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
|
||||
Tx: map[string]any{
|
||||
"to": strings.ToLower(a.cfg.MembershipContract),
|
||||
"value": "0x0",
|
||||
"data": "0x",
|
||||
"from": quote.PayerWallet,
|
||||
"to": quote.ExpectedTxTo,
|
||||
"value": quote.ExpectedTxValueHex,
|
||||
"data": quote.ExpectedTxData,
|
||||
},
|
||||
AccessClass: quote.AccessClass,
|
||||
AvailabilityState: quote.AvailabilityState,
|
||||
@ -535,7 +553,15 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
||||
if expectedPayer == "" {
|
||||
expectedPayer = wallet
|
||||
}
|
||||
if err := verifyTransactionSenderOnChain(r.Context(), a.cfg, req.TxHash, expectedPayer); err != nil {
|
||||
if err := verifyTransactionCallOnChain(
|
||||
r.Context(),
|
||||
a.cfg,
|
||||
req.TxHash,
|
||||
expectedPayer,
|
||||
quote.ExpectedTxTo,
|
||||
quote.ExpectedTxData,
|
||||
quote.ExpectedTxValueHex,
|
||||
); err != nil {
|
||||
writeErrorCode(w, http.StatusConflict, "tx_verification_failed", fmt.Sprintf("tx verification pending/failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -141,6 +141,9 @@ type marketplaceQuoteRecord struct {
|
||||
PolicyHash string
|
||||
AccessClass string
|
||||
AvailabilityState string
|
||||
ExpectedTxTo string
|
||||
ExpectedTxData string
|
||||
ExpectedTxValueHex string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ConfirmedAt *time.Time
|
||||
|
||||
@ -97,6 +97,9 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
policy_hash TEXT NOT NULL,
|
||||
access_class TEXT NOT NULL,
|
||||
availability_state TEXT NOT NULL,
|
||||
expected_tx_to TEXT,
|
||||
expected_tx_data TEXT,
|
||||
expected_tx_value_hex TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
confirmed_at TEXT,
|
||||
@ -239,6 +242,15 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_to", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_data", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_value_hex", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -420,8 +432,8 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
|
||||
INSERT INTO marketplace_quotes (
|
||||
quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(quote_id) DO UPDATE SET
|
||||
wallet=excluded.wallet,
|
||||
payer_wallet=excluded.payer_wallet,
|
||||
@ -439,6 +451,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
|
||||
policy_hash=excluded.policy_hash,
|
||||
access_class=excluded.access_class,
|
||||
availability_state=excluded.availability_state,
|
||||
expected_tx_to=excluded.expected_tx_to,
|
||||
expected_tx_data=excluded.expected_tx_data,
|
||||
expected_tx_value_hex=excluded.expected_tx_value_hex,
|
||||
created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at,
|
||||
confirmed_at=excluded.confirmed_at,
|
||||
@ -460,6 +475,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
|
||||
strings.TrimSpace(quote.PolicyHash),
|
||||
strings.ToLower(strings.TrimSpace(quote.AccessClass)),
|
||||
strings.ToLower(strings.TrimSpace(quote.AvailabilityState)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxTo))),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxData))),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxValueHex))),
|
||||
quote.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
quote.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||
formatNullableTime(quote.ConfirmedAt),
|
||||
@ -472,12 +490,13 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
FROM marketplace_quotes
|
||||
WHERE quote_id = ?
|
||||
`, strings.TrimSpace(quoteID))
|
||||
var rec marketplaceQuoteRecord
|
||||
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
|
||||
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
|
||||
var membershipIncluded int
|
||||
err := row.Scan(
|
||||
@ -498,6 +517,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
|
||||
&rec.PolicyHash,
|
||||
&rec.AccessClass,
|
||||
&rec.AvailabilityState,
|
||||
&expectedTxTo,
|
||||
&expectedTxData,
|
||||
&expectedTxValueHex,
|
||||
&createdAt,
|
||||
&expiresAt,
|
||||
&confirmedAt,
|
||||
@ -514,6 +536,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
|
||||
rec.PrincipalID = principalID.String
|
||||
rec.PrincipalRole = principalRole.String
|
||||
rec.WorkspaceID = workspaceID.String
|
||||
rec.ExpectedTxTo = expectedTxTo.String
|
||||
rec.ExpectedTxData = expectedTxData.String
|
||||
rec.ExpectedTxValueHex = expectedTxValueHex.String
|
||||
rec.MembershipIncluded = membershipIncluded == 1
|
||||
rec.CreatedAt = parseRFC3339Nullable(createdAt)
|
||||
rec.ExpiresAt = parseRFC3339Nullable(expiresAt)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user