本文是关于构建基于区块链的葡萄酒交易市场的系列文章的第三部分,深入探讨了后端基础设施,涵盖了驱动市场的服务和API。重点介绍了系统如何处理IPFS存储、智能合约验证以及区块链和链下数据库之间的OpenSea通信,使用了Java后端,集成了Pinata IPFS用于存储NFT元数据,Arbiscan API用于确保智能合约源代码的可公开验证,OpenSea API用于手动触发元数据更新。
在第一部分中,我们探讨了基于区块链的葡萄酒市场的后端架构和智能合约开发。然后在第二部分中,我们专注于前端集成,展示了 Web 市场如何使用 Ethers.js 和 MetaMask 连接到 WineCollection 智能合约。
现在,在第三部分中,我们将深入研究后端基础设施,涵盖为市场提供支持的服务和 API。我们将探讨系统如何处理区块链和链下数据库之间的 IPFS 存储、智能合约验证和 OpenSea 通信。
用 Java 构建的后端负责与关键的去中心化服务进行交互:
为了以去中心化的方式将 NFT 元数据链下存储,我们将 Pinata IPFS 集成到我们的 Java 后端。
🔹 注意: 虽然 固定文件 可以删除,但无论如何都不能修改,从而确保数据的不变性并防止任何更改。
此 pinata.properties
Spring 配置定义了 API 密钥、基本 API URL 和 IPFS 网关 URL,用于将 Pinata 的去中心化存储与 Spring 应用程序集成。
pinata.apiKey=
pinata.secretApiKey=
pinata.baseApiUrl=https://api.pinata.cloud
pinata.ipfsGatewayUrl=https://gateway.pinata.cloud/ipfs
pinata.baseUrl=https://gateway.pinata.cloud/ipfs
以下是 PinataServiceImpl
类中每个方法的简要说明:
getApiKey()
/ getSecretApiKey()
/ getBaseApiUrl()
/ getIpfsGatewayUrl()
/ getBaseUrl()
– 检索用于与 Pinata API 和 IPFS 网关交互的配置属性。uploadFile(content)
– 将文件上传到 Pinata 的 IPFS 存储并返回生成的 CID(内容标识符)。deleteFile(cid)
– 使用 CID 从 Pinata 的 IPFS 存储中删除文件,并返回删除是否成功。readFile(cid)
– 通过 Pinata 网关使用 CID 从 IPFS 获取文件的内容。getUrl(cid)
– 构造并返回直接 URL,以使用配置的基本 URL 访问 IPFS 上的文件。extractCID(responseBody)
– 解析 API 响应以从上传的文件中提取 CID。@Service
@Slf4j
public class PinataServiceImpl implements IPFSService {
@Autowired
private PinataConfiguration configuration;
private final RestTemplate restTemplate;
public PinataServiceImpl() {
this.restTemplate = new RestTemplate();
this.restTemplate.getMessageConverters().add(new FormHttpMessageConverter());
this.restTemplate.getMessageConverters().add(new StringHttpMessageConverter());
}
public String getApiKey() {
return configuration.getApiKey();
}
public String getSecretApiKey() {
return configuration.getSecretApiKey();
}
public String getBaseApiUrl() {
return configuration.getBaseApiUrl();
}
public String getIpfsGatewayUrl() {
return configuration.getIpfsGatewayUrl();
}
public String getBaseUrl() {
return configuration.getBaseUrl();
}
@Override
public String uploadFile(String content) throws Exception {
String url = getBaseApiUrl() + "/pinning/pinFileToIPFS";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
headers.set("pinata_api_key", getApiKey());
headers.set("pinata_secret_api_key", getSecretApiKey());
// Create the multipart body
// 创建 multipart 主体
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
@Override
public String getFilename() {
return "file.txt";
}
});
// Wrap the body in the HttpEntity
// 将主体包装在 HttpEntity 中
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
// Send POST request to Pinata
// 向 Pinata 发送 POST 请求
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
String.class
);
String cid = extractCID(response.getBody());
log.info("Generated IPFS CID: " + cid);
return cid;
}
@Override
public boolean deleteFile(String cid) throws Exception {
String url = getBaseApiUrl() + "/pinning/unpin/" + cid;
HttpHeaders headers = new HttpHeaders();
headers.set("pinata_api_key", getApiKey());
headers.set("pinata_secret_api_key", getSecretApiKey());
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.DELETE,
new HttpEntity<>(headers),
String.class
);
boolean isDeleted = response.getStatusCode().is2xxSuccessful();
if (isDeleted) {
log.info("CID {} deleted successfully.", cid);
} else {
log.error("Failed to delete CID {}. Status code: {}", cid, response.getStatusCode());
}
return isDeleted;
}
@Override
public String readFile(String cid) throws Exception {
String url = getIpfsGatewayUrl() + "/" + cid;
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
null,
String.class
);
if (response.getStatusCode().is2xxSuccessful()) {
return response.getBody();
} else {
throw new Exception("Failed to retrieve file with CID " + cid + ". Response: " + response.getBody());
}
}
@Override
public String getUrl(String cid) throws Exception {
return getBaseUrl() + "/" + cid;
}
// Helper method to extract CID from Pinata API response
// 从 Pinata API 响应中提取 CID 的辅助方法
private String extractCID(String responseBody) {
String cid = null;
if (responseBody != null && responseBody.contains("IpfsHash")) {
int startIndex = responseBody.indexOf("IpfsHash") + 11;
int endIndex = responseBody.indexOf("\"", startIndex);
cid = responseBody.substring(startIndex, endIndex);
}
return cid;
}
}
在生产环境中,仅使用 uploadFile()
,而其他方法则为了单元测试而实现。
为了确保我们的智能合约源代码可以公开验证,我们将 Arbiscan API 集成到我们的 Java 后端。
通过此集成,买家可以透明地检查合约代码,验证已部署的智能合约是否与其开源 Solidity 实现相匹配。 因此,买家可以确认关键保证,例如瓶子的固定供应量和专家评论的真实性,从而确保这些参数不会被更改或操纵。
此 web3.properties
Spring 配置定义了 API 密钥、基本 API URL、arbiscan 网页 URL、arbitrum solidity 版本 URL,用于将 arbitrum 与 Spring 应用程序集成。
web3.blockchainNetwork=sepoliaArbitrum
web3.sepoliaArbitrumScanUrl=https://sepolia.arbiscan.io/address
web3.arbitrumScanUrl=https://arbiscan.io/address
web3.arbiscanApiKey=
web3.sepoliaArbitrumApiUrl=https://api-sepolia.arbiscan.io/api
web3.arbitrumApiUrl=https://api.arbiscan.io/api
web3.arbiscanCompileVersionsUrl=https://arbiscan.io/solcversions
合约验证包括两个操作:发送一个异步请求来验证合约(最终将被执行)和检查状态以确定合约是否已验证。 我们已决定仅触发第一个操作。
verifyContract(request)
:使用提供的元数据(包括源代码、编译器版本和构造函数参数)发送合约验证请求。 返回一个指示请求是否成功的响应。checkVerificationStatus(apiUrl, apiKey, guid)
:查询验证 API 以检查合约验证过程是否已成功完成。未使用。encodeConstructorArguments(contractName, contractSymbol, ownerAddress, maxNft, contractUri)
:将构造函数参数编码为合约验证所需的十六进制格式。extractContractName(sourceCode)
:使用正则表达式从扁平化的 Solidity 源代码中提取合约名称。extractCompilerVersion(sourceCode):
从扁平化的 Solidity 源代码中提取编译器版本(compiler version)。getFullCompilerVersion(version, compilerVersionsUrl)
:从官方 Solidity Versions url 中获取完整的编译器版本字符串,确保它与验证所需的格式匹配。 不幸的是,我们没有找到更好的基于 API 的解决方案。fetchSourceCode(contractCodeURI)
:从给定的 URI 检索合约的扁平化 Solidity 源代码(请参阅第 1 部分:架构和智能代码 )。@Slf4j
public class ContractVerification {
private final RestTemplate restTemplate;
public ContractVerification() {
this.restTemplate = new RestTemplate();
this.restTemplate.getMessageConverters().add(new FormHttpMessageConverter());
this.restTemplate.getMessageConverters().add(new StringHttpMessageConverter());
}
public ContractVerificationResponse verifyContract(ContractVerificationRequest request)
throws ContractVerificationException {
String sourceCode = fetchSourceCode(request.getContractCodeUri());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("apikey", request.getApiKey());
formData.add("module", "contract");
formData.add("action", "verifysourcecode");
formData.add("sourceCode", sourceCode);
formData.add("contractaddress", request.getContractAddress());
formData.add("codeformat", "solidity-single-file");
formData.add("contractname", extractContractName(sourceCode));
String version = extractCompilerVersion(sourceCode); // gets eg "0.8.20"
// 获取 例如 "0.8.20"
String fullVersion = getFullCompilerVersion(version, request.getCompilerVersionsUrl()); // gets then "v0.8.20+commit.a1b79de6"
// 然后获取 "v0.8.20+commit.a1b79de6"
formData.add("compilerversion", fullVersion);
formData.add("optimizationUsed", "1");
formData.add("runs", "200");
formData.add("constructorArguements", encodeConstructorArguments(request.getContractName(),
request.getContractSymbol(), request.getOwnerAddress(), Long.valueOf(request.getMaxNft()),
request.getContractUri()));
formData.add("evmversion", "paris");
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
request.getApiUrl(),
HttpMethod.POST,
requestEntity,
String.class
);
JsonNode result = new ObjectMapper().readTree(response.getBody());
log.info("Requesting contract verification. Contract name {}, Address {}",
request.getContractName(), request.getContractAddress());
String statusCode = result.get("status").asText();
String verificationResult = result.get("result").asText();
VerifyAPIRequestStatus requestStatus = VerifyAPIRequestStatus.fromStatusCode(statusCode);
if (requestStatus == VerifyAPIRequestStatus.SUCCESS) {
log.info("Contract verification successfully requested. Guid: {}", verificationResult);
/*we decided not verify the final status of the contractVerification, but just expect it to be eventually occur
sometime in future
*/
/*我们决定不验证 contractVerification 的最终状态,而只是期望它最终在将来的某个时候发生
*/
return new ContractVerificationResponse(true, "Contract verification requested successfully");
}
log.info("Contract verification request failed. Reason: {}", verificationResult);
return new ContractVerificationResponse(false, "Verification verification request failed." +
"Reason: " + verificationResult);
} catch (Exception e) {
log.error("Contract verification failed", e);
throw new ContractVerificationException("Contract verification failed", e);
}
}
private boolean checkVerificationStatus(String apiUrl, String apiKey, String guid) throws Exception {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("apikey", apiKey);
params.add("module", "contract");
params.add("action", "checkverifystatus");
params.add("guid", guid);
HttpHeaders headers = new HttpHeaders();
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(apiUrl)
.queryParams(params);
ResponseEntity<String> response = restTemplate.exchange(
builder.build().encode().toUri(),
HttpMethod.GET,
requestEntity,
String.class
);
JsonNode result = new ObjectMapper().readTree(response.getBody());
String status = result.get("result").asText();
log.info("Verification status: {}", status);
if ("Pass - Verified".equals(status) || "Already Verified".equals(status)) {
return true;
}
return false;
}
private String encodeConstructorArguments(
String contractName,
String contractSymbol,
String ownerAddress,
Long maxNft,
String contractUri
) throws ContractVerificationException {
try {
DefaultFunctionEncoder encoder = new DefaultFunctionEncoder();
List<Type> params = Arrays.asList(
new Utf8String(contractName),
new Utf8String(contractSymbol),
new Address(160, ownerAddress), // Address needs bit length
// 地址需要位长度
new Uint256(BigInteger.valueOf(maxNft)),
new Utf8String(contractUri)
);
String encodedParams = encoder.encodeParameters(params);
return encodedParams.startsWith("0x") ? encodedParams.substring(2) : encodedParams;
} catch (Exception e) {
throw new ContractVerificationException("Failed to encode constructor arguments", e);
}
}
private String extractContractName(String sourceCode) throws ContractVerificationException {
Pattern pattern = Pattern.compile("^\\s*contract (\\w+) is", Pattern.MULTILINE);
Matcher matcher = pattern.matcher(sourceCode);
if (matcher.find()) {
return matcher.group(1);
}
throw new ContractVerificationException("Could not find contract name in source code");
}
private String extractCompilerVersion(String sourceCode) throws ContractVerificationException {
Pattern pattern = Pattern.compile("pragma solidity \\^?(\\d+\\.\\d+\\.\\d+)");
Matcher matcher = pattern.matcher(sourceCode);
if (matcher.find()) {
return "v" + matcher.group(1);
}
throw new ContractVerificationException("Could not find compiler version in source code");
}
private String getFullCompilerVersion(String version, String compilerVersionsUrl) throws ContractVerificationException {
try {
ResponseEntity<String> response = restTemplate.getForEntity(compilerVersionsUrl, String.class);
String html = response.getBody();
if (html == null || html.isEmpty()) {
throw new ContractVerificationException("Empty response received from: " + compilerVersionsUrl);
}
// Look for pattern like: v0.8.20+commit.a1b79de6 while excluding nightly versions
// 寻找类似 v0.8.20+commit.a1b79de6 的模式,同时排除 nightly 版本
Pattern pattern = Pattern.compile(version + "\\+commit\\.[a-f0-9]+(?!-nightly)");
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
return matcher.group(); // Extracts the full version string
// 提取完整的版本字符串
}
throw new ContractVerificationException("Could not find full compiler version for: " + version);
} catch (Exception e) {
throw new ContractVerificationException("Failed to get compiler version info", e);
}
}
private String fetchSourceCode(String contractCodeURI) throws ContractVerificationException {
try {
ResponseEntity<String> response = restTemplate.getForEntity(contractCodeURI, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new ContractVerificationException("Failed to fetch contract source code: " + response.getStatusCode());
}
return response.getBody();
} catch (Exception e) {
throw new ContractVerificationException("Error fetching contract source code", e);
}
}
}
@RequiredArgsConstructor
public enum VerifyAPIRequestStatus {
FAILURE("0"),
// 失败
SUCCESS("1");
// 成功
@Getter
private final String statusCode;
public static VerifyAPIRequestStatus fromStatusCode(String statusCode) {
for (VerifyAPIRequestStatus status : values()) {
if (status.statusCode.equals(statusCode)) {
return status;
}
}
throw new IllegalArgumentException("Unknown status code: " + statusCode);
// 未知状态代码
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContractVerificationRequest {
private String contractAddress;
private String contractName;
private String contractSymbol;
private String ownerAddress;
private String maxNft;
private String contractUri;
private String contractCodeUri;
private String chainName;
private String apiUrl;
private String apiKey;
private String compilerVersionsUrl;
}
@Getter
@AllArgsConstructor
public class ContractVerificationResponse {
private final boolean success;
private final String message;
}
public class ContractVerificationException extends Exception {
public ContractVerificationException(String message) {
super(message);
}
public ContractVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
虽然 OpenSea 在 Arbitrum 上的更改后最终会更新 NFT 元数据,但我们显式触发元数据刷新以确保更快的更新。
此 opensea.properties
Spring 配置定义了 API 密钥、基本 API URL 以及Token和集合 URL 以将 OpenSea 与 Spring 应用程序集成。
opensea.sepoliaArbitrumApiUrl=https://testnets-api.opensea.io/api/v2/chain/arbitrum_sepolia
opensea.sepoliaArbitrumTokenUrl=https://testnets.opensea.io/assets/arbitrum-sepolia
opensea.arbitrumApiUrl=https://api.opensea.io/api/v2/chain/arbitrum
opensea.arbitrumTokenUrl=https://opensea.io/assets/arbitrum
opensea.collectionUrlTest=https://testnets.opensea.io
opensea.collectionUrlProd=https://opensea.io
opensea.apiKey=
OpenSeaServiceImpl
类实现 NFTMarketplaceService
,并提供用于与 OpenSea 交互的方法,包括刷新 NFT 元数据、检索集合 URL 和生成Token URL,使用带有身份验证的 REST API 调用。
refreshMetadata(contractAddress, tokenId, chain)
:向 OpenSea API 发送 POST 请求以刷新特定 NFT 的元数据,并返回一个布尔值,指示成功或失败。getCollectionUrl(contractAddress, chain)
:根据合约地址检索 NFT 集合的面向公众的 URL。getTokenUrl(contractAddress, tokenId, chain)
:构造并返回特定 NFT Token的面向公众的 URL。@Service
public class OpenSeaServiceImpl implements NFTMarketplaceService {
private static final Logger log = LoggerFactory.getLogger(OpenSeaServiceImpl.class);
@Autowired
private OpenSeaConfiguration configuration;
private final RestTemplate restTemplate;
public OpenSeaServiceImpl() {
this.restTemplate = new RestTemplate();
}
public String getApiKey() {
return configuration.getApiKey();
}
public String getApiUrl(String chain) {
return configuration.getApiUrl(chain);
}
@Override
public boolean refreshMetadata(String contractAddress, String tokenId, String chain) {
String url = String.format("%s/contract/%s/nfts/%s/refresh", getApiUrl(chain), contractAddress, tokenId);
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("Authorization", "Bearer " + getApiKey());
HttpEntity<String> entity = new HttpEntity<>("", headers);
try {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info(String.format("Successfully queued metadata refresh for contract %s and token_id %s", contractAddress, tokenId));
return true;
} else {
log.error(String.format("Failed to refresh metadata for token %s: %s", tokenId, response.getStatusCode()));
return false;
}
} catch (Exception e) {
log.error("Error refreshing metadata: " + e.getMessage());
return false;
}
}
@Override
public String getCollectionUrl(String contractAddress, String chain) {
String apiUrl = getApiUrl(chain);
String collectionBaseUrl = configuration.getBaseCollectionUrl(chain);
String url = String.format("%s/contract/%s", apiUrl, contractAddress);
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/json");
headers.set("Authorization", "Bearer " + getApiKey());
HttpEntity<String> entity = new HttpEntity<>(headers);
try {
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
Map<String, Object> responseBody = response.getBody();
String slug = (String) responseBody.get("collection");
if (slug != null) {
return String.format("%s/collection/%s", collectionBaseUrl, slug);
} else {
log.warn("Collection slug not found for contract address: " + contractAddress);
}
} else {
log.error("Failed to retrieve collection information: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error fetching collection URI: " + e.getMessage());
}
return null;
}
@Override
public String getTokenUrl(String contractAddress, String tokenId, String chain) {
String baseTokenUrl = configuration.getBaseTokenUrl(chain);
return String.format("%s/%s/%s", baseTokenUrl, contractAddress, tokenId);
}
}
通过这段旅程,我们探索了由区块链驱动的葡萄酒市场的端到端开发,使用 Arbitrum 上的 NFT 确保真实性、来源和安全所有权。
在使用区块链彻底改变葡萄酒行业:我们在 Arbitrum 上建立酒庄的旅程中,我们收集了功能需求并编写了详细的用例,用于在葡萄酒行业中实施区块链技术,特别是在 Arbitrum 网络上建立酒庄。 我们的结论强调了我们面临的挑战和限制。
🔹 第 3 部分:后端集成
- 原文链接: coinsbench.com/building-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!