From 0696762d248ef5b375f85e424c4d2f69ef01b2a0 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 13:28:01 -0800 Subject: [PATCH] Emit entitlement calldata in quotes and verify tx payload --- backend/secretapi/.env.example | 1 + backend/secretapi/README.md | 1 + backend/secretapi/app_test.go | 33 ++++++++++ backend/secretapi/chain.go | 83 +++++++++++++++++++++++-- backend/secretapi/config.go | 2 + backend/secretapi/marketplace.go | 34 ++++++++-- backend/secretapi/marketplace_models.go | 3 + backend/secretapi/store.go | 31 ++++++++- 8 files changed, 175 insertions(+), 13 deletions(-) diff --git a/backend/secretapi/.env.example b/backend/secretapi/.env.example index dc092cc..f664c85 100644 --- a/backend/secretapi/.env.example +++ b/backend/secretapi/.env.example @@ -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 diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index 616b4a3..b49bbfa 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -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 diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index c1328fa..4dbb68d 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -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() diff --git a/backend/secretapi/chain.go b/backend/secretapi/chain.go index d3795ed..11cbb20 100644 --- a/backend/secretapi/chain.go +++ b/backend/secretapi/chain.go @@ -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) } - 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 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))) +} diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go index 0620cc7..0145db2 100644 --- a/backend/secretapi/config.go +++ b/backend/secretapi/config.go @@ -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), diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 933a7b0..1719066 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -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 } diff --git a/backend/secretapi/marketplace_models.go b/backend/secretapi/marketplace_models.go index cf96d58..2c7eab4 100644 --- a/backend/secretapi/marketplace_models.go +++ b/backend/secretapi/marketplace_models.go @@ -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 diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 6a93e37..0304c42 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -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)