Emit entitlement calldata in quotes and verify tx payload

This commit is contained in:
Joshua 2026-02-18 13:28:01 -08:00
parent 86a57b2888
commit 0696762d24
8 changed files with 175 additions and 13 deletions

View File

@ -12,6 +12,7 @@ SECRET_API_QUOTE_TTL_SECONDS=900
SECRET_API_DOMAIN_NAME=EDUT Designation SECRET_API_DOMAIN_NAME=EDUT Designation
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000 SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000 SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_ENTITLEMENT_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MINT_CURRENCY=USDC SECRET_API_MINT_CURRENCY=USDC
SECRET_API_MINT_AMOUNT_ATOMIC=100000000 SECRET_API_MINT_AMOUNT_ATOMIC=100000000
SECRET_API_MINT_DECIMALS=6 SECRET_API_MINT_DECIMALS=6

View File

@ -86,6 +86,7 @@ Company-first sponsor path is also supported:
- `SECRET_API_CHAIN_ID` (default `84532`) - `SECRET_API_CHAIN_ID` (default `84532`)
- `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification) - `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_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 ### Membership

View File

@ -6,6 +6,7 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "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) { func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) {
a, _, cleanup := newTestApp(t) a, _, cleanup := newTestApp(t)
defer cleanup() defer cleanup()

View File

@ -9,11 +9,14 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient" "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 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) { func encodeMintMembershipCalldata(recipient string) (string, error) {
parsed, err := abi.JSON(strings.NewReader(membershipABI)) parsed, err := abi.JSON(strings.NewReader(membershipABI))
if err != nil { 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 { 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) == "" { if strings.TrimSpace(cfg.ChainRPCURL) == "" {
return nil return nil
} }
expectedSender = strings.TrimSpace(expectedSender) expectedSender = strings.TrimSpace(expectedSender)
if expectedSender == "" { expectedTo = strings.TrimSpace(expectedTo)
return fmt.Errorf("expected sender missing") 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) 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) return fmt.Errorf("resolve tx sender: %w", err)
} }
if expectedSender != "" {
gotSender := strings.ToLower(from.Hex()) gotSender := strings.ToLower(from.Hex())
wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex()) wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex())
if gotSender != wantSender { if gotSender != wantSender {
return fmt.Errorf("tx sender mismatch got=%s want=%s", 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 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)))
}

View File

@ -22,6 +22,7 @@ type Config struct {
DomainName string DomainName string
VerifyingContract string VerifyingContract string
MembershipContract string MembershipContract string
EntitlementContract string
MintCurrency string MintCurrency string
MintAmountAtomic string MintAmountAtomic string
MintDecimals int MintDecimals int
@ -51,6 +52,7 @@ func loadConfig() Config {
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"), DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")), VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_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")), MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"), MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6), MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),

View File

@ -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") writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
return 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() now := time.Now().UTC()
expiresAt := now.Add(a.cfg.QuoteTTL) expiresAt := now.Add(a.cfg.QuoteTTL)
accessClass := "connected" accessClass := "connected"
@ -400,6 +414,9 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
PolicyHash: a.cfg.GovernancePolicyHash, PolicyHash: a.cfg.GovernancePolicyHash,
AccessClass: accessClass, AccessClass: accessClass,
AvailabilityState: "active", AvailabilityState: "active",
ExpectedTxTo: txTo,
ExpectedTxData: txData,
ExpectedTxValueHex: txValueHex,
CreatedAt: now, CreatedAt: now,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
} }
@ -427,9 +444,10 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
PolicyHash: quote.PolicyHash, PolicyHash: quote.PolicyHash,
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano), ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
Tx: map[string]any{ Tx: map[string]any{
"to": strings.ToLower(a.cfg.MembershipContract), "from": quote.PayerWallet,
"value": "0x0", "to": quote.ExpectedTxTo,
"data": "0x", "value": quote.ExpectedTxValueHex,
"data": quote.ExpectedTxData,
}, },
AccessClass: quote.AccessClass, AccessClass: quote.AccessClass,
AvailabilityState: quote.AvailabilityState, AvailabilityState: quote.AvailabilityState,
@ -535,7 +553,15 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
if expectedPayer == "" { if expectedPayer == "" {
expectedPayer = wallet 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)) writeErrorCode(w, http.StatusConflict, "tx_verification_failed", fmt.Sprintf("tx verification pending/failed: %v", err))
return return
} }

View File

@ -141,6 +141,9 @@ type marketplaceQuoteRecord struct {
PolicyHash string PolicyHash string
AccessClass string AccessClass string
AvailabilityState string AvailabilityState string
ExpectedTxTo string
ExpectedTxData string
ExpectedTxValueHex string
CreatedAt time.Time CreatedAt time.Time
ExpiresAt time.Time ExpiresAt time.Time
ConfirmedAt *time.Time ConfirmedAt *time.Time

View File

@ -97,6 +97,9 @@ func (s *store) migrate(ctx context.Context) error {
policy_hash TEXT NOT NULL, policy_hash TEXT NOT NULL,
access_class TEXT NOT NULL, access_class TEXT NOT NULL,
availability_state 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, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
confirmed_at TEXT, 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 { if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil {
return err 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 return nil
} }
@ -420,8 +432,8 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
INSERT INTO marketplace_quotes ( INSERT INTO marketplace_quotes (
quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id, 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, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(quote_id) DO UPDATE SET ON CONFLICT(quote_id) DO UPDATE SET
wallet=excluded.wallet, wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet, payer_wallet=excluded.payer_wallet,
@ -439,6 +451,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
policy_hash=excluded.policy_hash, policy_hash=excluded.policy_hash,
access_class=excluded.access_class, access_class=excluded.access_class,
availability_state=excluded.availability_state, 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, created_at=excluded.created_at,
expires_at=excluded.expires_at, expires_at=excluded.expires_at,
confirmed_at=excluded.confirmed_at, confirmed_at=excluded.confirmed_at,
@ -460,6 +475,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
strings.TrimSpace(quote.PolicyHash), strings.TrimSpace(quote.PolicyHash),
strings.ToLower(strings.TrimSpace(quote.AccessClass)), strings.ToLower(strings.TrimSpace(quote.AccessClass)),
strings.ToLower(strings.TrimSpace(quote.AvailabilityState)), 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.CreatedAt.UTC().Format(time.RFC3339Nano),
quote.ExpiresAt.UTC().Format(time.RFC3339Nano), quote.ExpiresAt.UTC().Format(time.RFC3339Nano),
formatNullableTime(quote.ConfirmedAt), formatNullableTime(quote.ConfirmedAt),
@ -472,12 +490,13 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id, 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, 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 FROM marketplace_quotes
WHERE quote_id = ? WHERE quote_id = ?
`, strings.TrimSpace(quoteID)) `, strings.TrimSpace(quoteID))
var rec marketplaceQuoteRecord var rec marketplaceQuoteRecord
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
var membershipIncluded int var membershipIncluded int
err := row.Scan( err := row.Scan(
@ -498,6 +517,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
&rec.PolicyHash, &rec.PolicyHash,
&rec.AccessClass, &rec.AccessClass,
&rec.AvailabilityState, &rec.AvailabilityState,
&expectedTxTo,
&expectedTxData,
&expectedTxValueHex,
&createdAt, &createdAt,
&expiresAt, &expiresAt,
&confirmedAt, &confirmedAt,
@ -514,6 +536,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
rec.PrincipalID = principalID.String rec.PrincipalID = principalID.String
rec.PrincipalRole = principalRole.String rec.PrincipalRole = principalRole.String
rec.WorkspaceID = workspaceID.String rec.WorkspaceID = workspaceID.String
rec.ExpectedTxTo = expectedTxTo.String
rec.ExpectedTxData = expectedTxData.String
rec.ExpectedTxValueHex = expectedTxValueHex.String
rec.MembershipIncluded = membershipIncluded == 1 rec.MembershipIncluded = membershipIncluded == 1
rec.CreatedAt = parseRFC3339Nullable(createdAt) rec.CreatedAt = parseRFC3339Nullable(createdAt)
rec.ExpiresAt = parseRFC3339Nullable(expiresAt) rec.ExpiresAt = parseRFC3339Nullable(expiresAt)